diff --git a/docs/api/simadmin-upstream-58e2204.md b/docs/api/simadmin-upstream-58e2204.md new file mode 100644 index 0000000..1c85a6e --- /dev/null +++ b/docs/api/simadmin-upstream-58e2204.md @@ -0,0 +1,205 @@ +# SimAdmin upstream API static-contract evidence matrix — `58e2204` + +- **Audited commit:** `58e220411d6599609f0eeda01eb7016e9212f970`. +- **Coverage:** 100 paths / 117 operations (GET 50, POST 62, DELETE 5); IA domain/owner mapping remains frozen. +- **Independent snapshot:** repository fixture `packages/operation-registry/test/fixtures/upstream-58e2204/` contains whole upstream source and all 82 Bruno `.bru` files. `manifest.json` records commit and SHA-256 for every whole file; tests re-hash files and exact handler slices without `/tmp` or registry-derived content. +- **Exact handlers:** each `handlerRange` starts at the async function signature and ends at its brace-matched closing brace. Helper evidence, when needed, belongs in `supplementalRanges`, never in the handler digest. +- **Response evidence:** explicit Rust returns, `ApiResponse` payload construction, or dynamic JSON construction are implementation evidence, not real response fixtures or an exhaustive schema. `opaque-into-response` count: **0**. +- **Bruno evidence:** 67/117 operations reference parsed Bruno evidence from 82 files. This does **not** claim that 82 files cover all 117 operations; operations without Bruno are marked route+handler(+model). +- **Sensitive fields:** every item carries snapshot-rebuildable evidence (file/range/SHA-256/symbol/model field), with explicit container model chains for array paths and exact handler slices for dynamic keys. +- **Boundary / semanticBindingReview:** structural checks prove source/model/field relationships, not semantic payload intent. Each registry operation is marked `semanticBindingReview: 'reviewed-phase-0.2'`; manual review fixes the method/path/handler/payload-symbol tuple, especially high-value response bindings. +- **Policy:** every R2 requires at least explicit confirmation; every R3 requires strong confirmation. Auth/session-sensitive operations use dedicated flow/replay protections. +- **Phase 0.3 仅负责采集真实响应样本** (`responseFixtureStatus=pending-phase-0.3`); static evidence is not a runtime fixture. + +## Whole-file snapshot SHA-256 + +See `upstream-58e2204/manifest.json`; it is machine-verified on every test run and is the authoritative complete whole-file SHA list. + +## All operations + +| Method | Path | operationId | Domain | uiOwner | Handler | Evidence level | Content-Type | Idempotency | Risk / policy | Request contract | Response contract | Evidence digest | +|---|---|---|---|---|---|---|---|---|---|---|---|---| +| `GET` | `/api/device` | `getDevice` | device-system | overview | `get_device_info` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1076-1090 | ApiResponse success payload/error envelope; success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1076-1090 | `7b4d3ba9a85015e5` | +| `GET` | `/api/sim` | `getSim` | sim | overview | `get_sim_info` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1126-1145 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1126-1145 | `db5280e4e6d37c43` | +| `POST` | `/api/sim/details/refresh` | `postSimDetailsRefresh` | sim | overview | `refresh_sim_details_handler` | route+handler | none | non-idempotent | R1 / command / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1148-1164 | dynamic-json construction in exact handler backend/src/handlers.rs:1148-1164; field shape is implementation evidence only | `ab04a42ae36c3495` | +| `POST` | `/api/sim/cache` | `postSimCache` | sim | overview | `update_sim_cache_handler` | route+handler+model | application/json | conditional | R1 / command / confirm:none / registeredOperation | body extractor UpdateSimCacheRequest fields [phone_number:Option, sms_center:Option] (models.rs:1036); exact handler backend/src/handlers.rs:1167-1213 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1167-1213 | `b2b5f366ff8b133d` | +| `GET` | `/api/network` | `getNetwork` | cellular | cellular | `get_network_info` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1218-1232 | ApiResponse success payload/error envelope; success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1218-1232 | `5d02208a69b79024` | +| `GET` | `/api/cells` | `getCells` | cellular | cellular | `get_cells` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1235-1249 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1235-1249 | `2a0eb9db873a461a` | +| `POST` | `/api/cell-monitor/start` | `postCellMonitorStart` | cellular | cellular | `start_cell_monitor_handler` | route+handler | none | conditional | R1 / command / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1252-1282 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1252-1282 | `c70d5916a800de22` | +| `POST` | `/api/cell-monitor/stop` | `postCellMonitorStop` | cellular | cellular | `stop_cell_monitor_handler` | route+handler | none | conditional | R1 / command / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1285-1312 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1285-1312 | `81303fb59efb78b8` | +| `GET` | `/api/radio-mode` | `getRadioMode` | radio-lock | cellular | `get_radio_mode_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1315-1329 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1315-1329 | `caba610280c2850f` | +| `POST` | `/api/radio-mode` | `postRadioMode` | radio-lock | cellular | `set_radio_mode_handler` | route+handler+model+bruno | application/json | conditional | R1 / command / confirm:none / registeredOperation | body extractor RadioModeRequest fields [mode:RadioMode] (models.rs:351); exact handler backend/src/handlers.rs:1332-1352 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1332-1352 | `c41c6134fe1f28b0` | +| `GET` | `/api/band-lock` | `getBandLock` | radio-lock | cellular | `get_band_lock_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1355-1369 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1355-1369 | `6130c68b619423b3` | +| `POST` | `/api/band-lock` | `postBandLock` | radio-lock | cellular | `set_band_lock_handler` | route+handler+model+bruno | application/json | conditional | R2 / job / confirm:explicit / registeredOperation | body extractor BandLockRequest fields [lte_fdd_bands:Vec, lte_tdd_bands:Vec, nr_fdd_bands:Vec, nr_tdd_bands:Vec] (models.rs:377); exact handler backend/src/handlers.rs:1372-1392 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1372-1392 | `04504f8b9b2d528e` | +| `GET` | `/api/network/interfaces` | `getNetworkInterfaces` | device-network | device-network | `get_network_interfaces_info` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1619-1644 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1619-1644 | `a1ac88c365ed70bf` | +| `GET` | `/api/network/connection-addresses` | `getNetworkConnectionAddresses` | device-network | device-network | `get_network_connection_addresses` | route+handler | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1647-1666 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1647-1666 | `a5f28ff95121c097` | +| `GET` | `/api/device-network/ddns/config` | `getDeviceNetworkDdnsConfig` | device-network | device-network | `get_device_ddns_config_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1669-1679 | dynamic-json construction in exact handler backend/src/handlers.rs:1669-1679; field shape is implementation evidence only | `4ec8e855d61695c9` | +| `POST` | `/api/device-network/ddns/config` | `postDeviceNetworkDdnsConfig` | device-network | device-network | `set_device_ddns_config_handler` | route+handler+model+bruno | application/json | conditional | R2 / job / confirm:explicit / registeredOperation | body extractor crate::config::DdnsConfig fields [enabled:bool, provider:String, access_id:String, access_secret:String, interval_seconds:u64, ttl:u32, ipv4:DdnsIpConfig, ipv6:DdnsIpConfig] (config.rs:634); exact handler backend/src/handlers.rs:1682-1721 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1682-1721 | `10137722721a3ef9` | +| `GET` | `/api/device-network/ddns/status` | `getDeviceNetworkDdnsStatus` | device-network | device-network | `get_device_ddns_status_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1750-1757 | dynamic-json construction in exact handler backend/src/handlers.rs:1750-1757; field shape is implementation evidence only | `02494bf7928a9a23` | +| `POST` | `/api/device-network/ddns/sync` | `postDeviceNetworkDdnsSync` | device-network | device-network | `sync_device_ddns_handler` | route+handler+bruno | none | conditional | R1 / command / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1760-1781 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1760-1781 | `43cf9573e2b43aaf` | +| `GET` | `/api/device-network/ddns/logs` | `getDeviceNetworkDdnsLogs` | device-network | device-network | `get_device_ddns_logs_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1784-1790 | dynamic-json construction in exact handler backend/src/handlers.rs:1784-1790; field shape is implementation evidence only | `ac80f3ada641ca13` | +| `POST` | `/api/device-network/ddns/logs/clear` | `postDeviceNetworkDdnsLogsClear` | device-network | device-network | `clear_device_ddns_logs_handler` | route+handler+bruno | none | conditional | R2 / job / confirm:explicit / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1793-1802 | dynamic-json construction in exact handler backend/src/handlers.rs:1793-1802; field shape is implementation evidence only | `8ebb7d7cfe772a4e` | +| `GET` | `/api/device-network/wlan/status` | `getDeviceNetworkWlanStatus` | device-network | device-network | `get_device_wlan_status_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1805-1819 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1805-1819 | `66741c07471de1f7` | +| `POST` | `/api/device-network/wlan/enabled` | `postDeviceNetworkWlanEnabled` | device-network | device-network | `set_device_wlan_enabled_handler` | route+handler+model+bruno | application/json | conditional | R1 / command / confirm:none / registeredOperation | body extractor WlanEnabledRequest fields [enabled:bool] (models.rs:606); exact handler backend/src/handlers.rs:1822-1841 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1822-1841 | `b6cc1238ca609208` | +| `POST` | `/api/device-network/wlan/scan` | `postDeviceNetworkWlanScan` | device-network | device-network | `scan_device_wlan_handler` | route+handler+bruno | none | non-idempotent | R1 / command / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1844-1858 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1844-1858 | `de90691d7c57a4e9` | +| `GET` | `/api/device-network/wlan/profiles` | `getDeviceNetworkWlanProfiles` | device-network | device-network | `get_device_wlan_profiles_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1861-1875 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1861-1875 | `caadf1fc1c436c63` | +| `POST` | `/api/device-network/wlan/forget` | `postDeviceNetworkWlanForget` | device-network | device-network | `forget_device_wlan_handler` | route+handler+model+bruno | application/json | conditional | R2 / job / confirm:explicit / registeredOperation | body extractor WlanForgetRequest fields [uuid:String, connection_id:String] (models.rs:635); exact handler backend/src/handlers.rs:1878-1897 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1878-1897 | `f5142f819f6da442` | +| `POST` | `/api/device-network/wlan/connect` | `postDeviceNetworkWlanConnect` | device-network | device-network | `connect_device_wlan_handler` | route+handler+model+bruno | application/json | non-idempotent | R1 / command / confirm:none / registeredOperation | body extractor WlanConnectRequest fields [ssid:String, password:String, auto_join:bool] (models.rs:611); exact handler backend/src/handlers.rs:1900-1955 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1900-1955 | `9c422666a3db49cd` | +| `POST` | `/api/device-network/wlan/disconnect` | `postDeviceNetworkWlanDisconnect` | device-network | device-network | `disconnect_device_wlan_handler` | route+handler+bruno | none | non-idempotent | R1 / command / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1958-1991 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1958-1991 | `719a709dc8509a35` | +| `POST` | `/api/device-network/wlan/profile` | `postDeviceNetworkWlanProfile` | device-network | device-network | `save_device_wlan_profile_handler` | route+handler+model+bruno | application/json | conditional | R1 / command / confirm:none / registeredOperation | body extractor WlanProfileRequest fields [connection_id:String, auto_join:Option, ipv4_mode:Option, ipv4_address:Option, ipv4_prefix:Option, ipv4_gateway:Option] (models.rs:620); exact handler backend/src/handlers.rs:1994-2013 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1994-2013 | `b3180f55af718c04` | +| `GET` | `/api/network/signal-strength` | `getNetworkSignalStrength` | cellular | cellular | `get_signal_strength_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:2016-2030 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2016-2030 | `2c04c31da8b4160b` | +| `GET` | `/api/location/cell-info` | `getLocationCellInfo` | cellular | cellular | `get_cell_location_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1395-1409 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1395-1409 | `fc748f05e03bf684` | +| `GET` | `/api/network/operators` | `getNetworkOperators` | cellular | cellular | `get_network_operators` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1412-1426 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1412-1426 | `d3b30db8a501b92a` | +| `GET` | `/api/network/operators/scan` | `getNetworkOperatorsScan` | cellular | cellular | `scan_network_operators` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1429-1443 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1429-1443 | `4a8bfbd38a943df8` | +| `POST` | `/api/network/register-manual` | `postNetworkRegisterManual` | cellular | cellular | `register_network_manual` | route+handler+model+bruno | application/json | non-idempotent | R2 / job / confirm:explicit / registeredOperation | body extractor ManualRegisterRequest fields [mccmnc:String] (models.rs:771); exact handler backend/src/handlers.rs:1446-1466 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1446-1466 | `8b78cc782499ff83` | +| `POST` | `/api/network/register-auto` | `postNetworkRegisterAuto` | cellular | cellular | `register_network_auto` | route+handler+bruno | none | non-idempotent | R2 / job / confirm:explicit / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1469-1486 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1469-1486 | `431aabcc9852603b` | +| `GET` | `/api/apn` | `getApn` | data-connection | cellular | `get_apn_list_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1489-1504 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1489-1504 | `798953ca7e3857d0` | +| `POST` | `/api/apn` | `postApn` | data-connection | cellular | `set_apn_handler` | route+handler+model+bruno | application/json | conditional | R1 / command / confirm:none / registeredOperation | body extractor SetApnRequest fields [context_path:String, apn:Option, protocol:Option, username:Option, password:Option, auth_method:Option] (models.rs:795); exact handler backend/src/handlers.rs:1507-1571 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1507-1571 | `6edced468c5f3bcb` | +| `GET` | `/api/cell-lock` | `getCellLock` | radio-lock | cellular | `get_cell_lock_status_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1574-1582 | dynamic-json construction in exact handler backend/src/handlers.rs:1574-1582; field shape is implementation evidence only | `fe4b4aaedc6e5ccf` | +| `POST` | `/api/cell-lock` | `postCellLock` | radio-lock | cellular | `set_cell_lock_handler` | route+handler+model+bruno | application/json | conditional | R2 / job / confirm:explicit / registeredOperation | body extractor CellLockRequest fields [rat:u8, enable:bool, lock_type:u8, pci:Option, arfcn:Option] (models.rs:405); exact handler backend/src/handlers.rs:1585-1603 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1585-1603 | `29ff2739b7d0b584` | +| `POST` | `/api/cell-lock/unlock-all` | `postCellLockUnlockAll` | radio-lock | cellular | `unlock_all_cells_handler` | route+handler+bruno | none | conditional | R2 / job / confirm:explicit / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1606-1616 | dynamic-json construction in exact handler backend/src/handlers.rs:1606-1616; field shape is implementation evidence only | `390162e5ce89c095` | +| `GET` | `/api/data` | `getData` | data-connection | cellular | `get_data_status` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:2035-2062 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2035-2062 | `43f00a519bd67c3a` | +| `POST` | `/api/data` | `postData` | data-connection | cellular | `set_data_status` | route+handler+model+bruno | application/json | conditional | R1 / command / confirm:none / registeredOperation | body extractor DataConnectionRequest fields [active:bool] (models.rs:234); exact handler backend/src/handlers.rs:2065-2131 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2065-2131 | `f1653cfe4442172c` | +| `GET` | `/api/roaming` | `getRoaming` | data-connection | cellular | `get_roaming_status_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:2172-2193 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2172-2193 | `687ff0cdb729645a` | +| `POST` | `/api/roaming` | `postRoaming` | data-connection | cellular | `set_roaming_status_handler` | route+handler+model+bruno | application/json | conditional | R1 / command / confirm:none / registeredOperation | body extractor RoamingRequest fields [allowed:bool] (models.rs:244); exact handler backend/src/handlers.rs:2196-2247 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2196-2247 | `b18b5c87eb24ccc9` | +| `GET` | `/api/airplane-mode` | `getAirplaneMode` | data-connection | cellular | `get_airplane_mode_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:2315-2329 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2315-2329 | `243c2a63fd99d45c` | +| `POST` | `/api/airplane-mode` | `postAirplaneMode` | data-connection | cellular | `set_airplane_mode_handler` | route+handler+model+bruno | application/json | conditional | R1 / command / confirm:none / registeredOperation | body extractor AirplaneModeRequest fields [enabled:bool] (models.rs:255); exact handler backend/src/handlers.rs:2250-2312 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2250-2312 | `5efb8a55c495ea0b` | +| `POST` | `/api/baseband/restart` | `postBasebandRestart` | data-connection | cellular | `restart_baseband_handler` | route+handler | none | non-idempotent | R3 / job / confirm:strong / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:2133-2159 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2133-2159 | `ef18782a4b21d5b4` | +| `GET` | `/api/baseband/restart/status` | `getBasebandRestartStatus` | data-connection | cellular | `get_baseband_restart_status_handler` | route+handler | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:2161-2169 | dynamic-json construction in exact handler backend/src/handlers.rs:2161-2169; field shape is implementation evidence only | `736cc766f31d5d71` | +| `GET` | `/api/work-mode` | `getWorkMode` | workmode-esim | esim | `get_work_mode_handler` | route+handler | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:352-365 | dynamic-json construction in exact handler backend/src/handlers.rs:352-365; field shape is implementation evidence only | `f2d3dca3f43326f8` | +| `POST` | `/api/work-mode` | `postWorkMode` | workmode-esim | esim | `set_work_mode_handler` | route+handler+model | application/json | conditional | R2 / job / confirm:explicit / registeredOperation | body extractor WorkModeRequest fields [mode:WorkMode, confirm:bool] (models.rs:57); exact handler backend/src/handlers.rs:368-405 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:368-405 | `cf17a54e0df74a9d` | +| `GET` | `/api/esim/config` | `getEsimConfig` | workmode-esim | esim | `get_esim_config_handler` | route+handler | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:456-462 | dynamic-json construction in exact handler backend/src/handlers.rs:456-462; field shape is implementation evidence only | `1be6b526e1a78451` | +| `POST` | `/api/esim/config` | `postEsimConfig` | workmode-esim | esim | `set_esim_config_handler` | route+handler+model | application/json | conditional | R2 / job / confirm:explicit / registeredOperation | body extractor crate::config::EsimConfig fields [lpac_path:String, custom_memory_total_kb:Option] (config.rs:1660); exact handler backend/src/handlers.rs:465-482 | dynamic-json construction in exact handler backend/src/handlers.rs:465-482; field shape is implementation evidence only | `b497f06687977d9f` | +| `GET` | `/api/esim/lpac/status` | `getEsimLpacStatus` | workmode-esim | esim | `get_esim_lpac_status_handler` | route+handler | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:408-416 | dynamic-json construction in exact handler backend/src/handlers.rs:408-416; field shape is implementation evidence only | `a4d5143d1f83089b` | +| `POST` | `/api/esim/lpac/repair` | `postEsimLpacRepair` | workmode-esim | esim | `repair_esim_lpac_handler` | route+handler+model | application/json | conditional | R2 / job / confirm:explicit / registeredOperation | body extractor EsimLpacRepairRequest fields [proxy_prefix:Option, asset_url:Option] (models.rs:153); exact handler backend/src/handlers.rs:419-453 | dynamic-json construction in exact handler backend/src/handlers.rs:419-453; field shape is implementation evidence only | `4d88af6126732e94` | +| `GET` | `/api/esim/euicc` | `getEsimEuicc` | workmode-esim | esim | `get_esim_euicc_handler` | route+handler | none | safe | R0 / query / confirm:none / registeredOperation | query extractor std::collections::HashMap; exact exact handler backend/src/handlers.rs:485-521 | dynamic-json construction in exact handler backend/src/handlers.rs:485-521; field shape is implementation evidence only | `7bb950b7fe21268d` | +| `GET` | `/api/esim/profiles` | `getEsimProfiles` | workmode-esim | esim | `get_esim_profiles_handler` | route+handler | none | safe | R0 / query / confirm:none / registeredOperation | query extractor std::collections::HashMap; exact exact handler backend/src/handlers.rs:524-598 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:524-598 | `07f60eddaa9d20ff` | +| `POST` | `/api/esim/profiles` | `postEsimProfiles` | workmode-esim | esim | `download_esim_profile_handler` | route+handler+model | application/json | conditional | R1 / command / confirm:none / registeredOperation | body extractor EsimDownloadRequest fields [smdp:String, matching_id:String, confirmation_code:Option, imei:Option] (models.rs:174); exact handler backend/src/handlers.rs:803-1071 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:803-1071 | `fe41273a15be55b3` | +| `POST` | `/api/esim/profiles/{iccid}/enable` | `postEsimProfilesIccidEnable` | workmode-esim | esim | `enable_esim_profile_handler` | route+handler | none | conditional | R1 / command / confirm:none / registeredOperation | path extractor String; exact handler backend/src/handlers.rs:601-726 | dynamic-json construction in exact handler backend/src/handlers.rs:601-726; field shape is implementation evidence only | `d54705546ca3bf2a` | +| `POST` | `/api/esim/profiles/{iccid}/rename` | `postEsimProfilesIccidRename` | workmode-esim | esim | `rename_esim_profile_handler` | route+handler+model | application/json | conditional | R1 / command / confirm:none / registeredOperation | body extractor EsimRenameRequest fields [name:String] (models.rs:169); path extractor String; exact handler backend/src/handlers.rs:729-750 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:729-750 | `0d5cb68158e1e6c2` | +| `DELETE` | `/api/esim/profiles/{iccid}` | `deleteEsimProfilesIccid` | workmode-esim | esim | `delete_esim_profile_handler` | route+handler | none | idempotent | R3 / job / confirm:strong / registeredOperation | path extractor String; exact handler backend/src/handlers.rs:753-780 | dynamic-json construction in exact handler backend/src/handlers.rs:753-780; field shape is implementation evidence only | `8ad25b4826f75d27` | +| `GET` | `/api/calls` | `getCalls` | calls | calls | `get_calls_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:2634-2655 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2634-2655 | `21e77021b749876b` | +| `POST` | `/api/call/dial` | `postCallDial` | calls | calls | `dial_call_handler` | route+handler+model+bruno | application/json | non-idempotent | R1 / command / confirm:none / registeredOperation | body extractor MakeCallRequest fields [phone_number:String] (models.rs:810); exact handler backend/src/handlers.rs:2657-2689 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2657-2689 | `c440bb90bb4373b3` | +| `POST` | `/api/call/hangup` | `postCallHangup` | calls | calls | `hangup_call_handler` | route+handler+model+bruno | application/json | non-idempotent | R1 / command / confirm:none / registeredOperation | body extractor HangupCallRequest fields [path:String] (models.rs:830); exact handler backend/src/handlers.rs:2691-2725 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2691-2725 | `cffdc6b2f949af4d` | +| `POST` | `/api/call/hangup-all` | `postCallHangupAll` | calls | calls | `hangup_all_calls_handler` | route+handler+bruno | none | non-idempotent | R1 / command / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:2727-2760 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2727-2760 | `3af8fab7bb4c0437` | +| `POST` | `/api/call/answer` | `postCallAnswer` | calls | calls | `answer_call_handler` | route+handler+model+bruno | application/json | non-idempotent | R1 / command / confirm:none / registeredOperation | body extractor HangupCallRequest fields [path:String] (models.rs:830); exact handler backend/src/handlers.rs:2762-2789 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2762-2789 | `cfcda6600d9ba27c` | +| `GET` | `/api/call/volume` | `getCallVolume` | calls | calls | `get_call_volume_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:2899-2906 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2899-2906 | `4f96c358e70ba5b3` | +| `POST` | `/api/call/volume` | `postCallVolume` | calls | calls | `set_call_volume_handler` | route+handler+model+bruno | application/json | conditional | R1 / command / confirm:none / registeredOperation | body extractor SetCallVolumeRequest fields [speaker_volume:Option, microphone_volume:Option, muted:Option] (models.rs:856); exact handler backend/src/handlers.rs:2908-2922 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2908-2922 | `9f2e624358e53aaf` | +| `GET` | `/api/call/forwarding` | `getCallForwarding` | calls | calls | `get_call_forwarding_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:2924-2931 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2924-2931 | `f7916eb4c5c484e5` | +| `POST` | `/api/call/forwarding` | `postCallForwarding` | calls | calls | `set_call_forwarding_handler` | route+handler+model+bruno | application/json | conditional | R1 / command / confirm:none / registeredOperation | body extractor SetCallForwardingRequest fields [forward_type:String, number:String, timeout:Option] (models.rs:891); exact handler backend/src/handlers.rs:2933-2943 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2933-2943 | `ddc8b848bccd96aa` | +| `GET` | `/api/call/settings` | `getCallSettings` | calls | calls | `get_call_settings_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:2852-2866 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2852-2866 | `893ccecb6c077f7e` | +| `POST` | `/api/call/settings` | `postCallSettings` | calls | calls | `set_call_settings_handler` | route+handler+model+bruno | application/json | conditional | R2 / job / confirm:explicit / registeredOperation | body extractor SetCallSettingRequest fields [property:String, value:String] (models.rs:875); exact handler backend/src/handlers.rs:2868-2897 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2868-2897 | `55ab650b3c53b78a` | +| `GET` | `/api/call/history` | `getCallHistory` | calls | calls | `get_call_history_handler` | route+handler+model | none | safe | R0 / query / confirm:none / registeredOperation | query extractor CallHistoryRequest fields [limit:i64, offset:i64] (models.rs:835); exact handler backend/src/handlers.rs:2791-2813 | Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2791-2813 | `1def94db4c3d848e` | +| `DELETE` | `/api/call/history/{id}` | `deleteCallHistoryId` | calls | calls | `delete_call_history_handler` | route+handler+bruno | none | idempotent | R3 / job / confirm:strong / registeredOperation | path extractor i64; exact handler backend/src/handlers.rs:2815-2832 | Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2815-2832 | `defa60d157769310` | +| `POST` | `/api/call/history/clear` | `postCallHistoryClear` | calls | calls | `clear_call_history_handler` | route+handler+bruno | none | conditional | R3 / job / confirm:strong / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:2834-2850 | Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2834-2850 | `b0e0002012256b5d` | +| `GET` | `/api/ims/status` | `getImsStatus` | calls | calls | `get_ims_status_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:2945-2952 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2945-2952 | `e533ac5c9e7bc694` | +| `GET` | `/api/voicemail/status` | `getVoicemailStatus` | calls | calls | `get_voicemail_status_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:2954-2961 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2954-2961 | `837b8fbc971099b9` | +| `POST` | `/api/sms/send` | `postSmsSend` | messages | messages | `send_sms_handler` | route+handler+model+bruno | application/json | non-idempotent | R2 / job / confirm:explicit / registeredOperation | body extractor SendSmsRequest fields [phone_number:String, content:String] (models.rs:912); exact handler backend/src/handlers.rs:2373-2404 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2373-2404 | `6cd7eaa5e1a1f939` | +| `GET` | `/api/sms/list` | `getSmsList` | messages | messages | `get_sms_list_handler` | route+handler+model | none | safe | R0 / query / confirm:none / registeredOperation | query extractor SmsListRequest fields [limit:i64, offset:i64, direction:Option] (models.rs:918); exact handler backend/src/handlers.rs:2407-2434 | Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2407-2434 | `abbc14adc02b2f5f` | +| `GET` | `/api/sms/conversation` | `getSmsConversation` | messages | messages | `get_sms_conversation_handler` | route+handler+model | none | safe | R0 / query / confirm:none / registeredOperation | query extractor SmsConversationRequest fields [phone_number:String, limit:i64] (models.rs:927); exact handler backend/src/handlers.rs:2437-2458 | Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2437-2458 | `e0747d062889fe8c` | +| `GET` | `/api/sms/stats` | `getSmsStats` | messages | messages | `get_sms_stats_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:2461-2477 | Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2461-2477 | `bd62f399815cb0d1` | +| `POST` | `/api/sms/batch-delete` | `postSmsBatchDelete` | messages | messages | `delete_sms_batch_handler` | route+handler+model | application/json | conditional | R3 / job / confirm:strong / registeredOperation | body extractor SmsBatchDeleteRequest fields [ids:Vec, phone_numbers:Vec] (models.rs:934); exact handler backend/src/handlers.rs:2551-2578 | Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2551-2578 | `94c5c83487401203` | +| `DELETE` | `/api/sms/conversation/{phone_number}` | `deleteSmsConversationPhoneNumber` | messages | messages | `delete_sms_conversation_handler` | route+handler | none | idempotent | R3 / job / confirm:strong / registeredOperation | path extractor String; exact handler backend/src/handlers.rs:2528-2548 | Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2528-2548 | `8abd673088da8720` | +| `DELETE` | `/api/sms/message/{id}` | `deleteSmsMessageId` | messages | messages | `delete_sms_message_handler` | route+handler | none | idempotent | R3 / job / confirm:strong / registeredOperation | path extractor i64; exact handler backend/src/handlers.rs:2508-2525 | Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2508-2525 | `8edc9f89854b5c8d` | +| `POST` | `/api/sms/clear` | `postSmsClear` | messages | messages | `clear_sms_handler` | route+handler+bruno | none | conditional | R3 / job / confirm:strong / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:2480-2505 | Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2480-2505 | `8c536214c43b65fc` | +| `GET` | `/api/stats` | `getStats` | device-system | overview | `get_system_stats` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:3272-3288 | dynamic-json construction in exact handler backend/src/handlers.rs:3272-3288; field shape is implementation evidence only | `94834f33d10be4b6` | +| `GET` | `/api/stats/cpu` | `getStatsCpu` | device-system | overview | `get_cpu_info` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:3291-3302 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:3291-3302 | `9f24be45c5c8df7a` | +| `GET` | `/api/connectivity` | `getConnectivity` | device-system | overview | `get_connectivity_check` | route+handler | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:3305-3322 | Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:3305-3322 | `42ba5f81f052b992` | +| `POST` | `/api/system/reboot` | `postSystemReboot` | device-system | overview | `system_reboot` | route+handler+model+bruno | application/json | non-idempotent | R3 / job / confirm:strong / registeredOperation | body extractor SystemRebootRequest fields [delay_seconds:u32] (models.rs:422); exact handler backend/src/handlers.rs:3381-3406 | dynamic-json construction in exact handler backend/src/handlers.rs:3381-3406; field shape is implementation evidence only | `362737633fd01332` | +| `POST` | `/api/service/restart` | `postServiceRestart` | device-system | overview | `restart_service_handler` | route+handler | none | non-idempotent | R3 / job / confirm:strong / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:3578-3601 | dynamic-json construction in exact handler backend/src/handlers.rs:3578-3601; field shape is implementation evidence only | `712a81c6cca94e0c` | +| `GET` | `/api/notifications/config` | `getNotificationsConfig` | notifications | notifications | `get_notification_config_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:3641-3652 | Rust return ( StatusCode, Json>, ); exact handler backend/src/handlers.rs:3641-3652 | `96344b8b2d1b8818` | +| `POST` | `/api/notifications/config` | `postNotificationsConfig` | notifications | notifications | `set_notification_config_handler` | route+handler+model+bruno | application/json | conditional | R2 / job / confirm:explicit / registeredOperation | body extractor crate::config::NotificationConfig fields [version:u8, channels:Vec, rules:Vec, log_cleanup:NotificationLogCleanupConfig] (config.rs:548); exact handler backend/src/handlers.rs:3655-3672 | Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:3655-3672 | `5fc4fbfff68050e0` | +| `POST` | `/api/notifications/test/{channel}` | `postNotificationsTestChannel` | notifications | notifications | `test_notification_channel_handler` | route+handler | none | non-idempotent | R2 / job / confirm:explicit / registeredOperation | path extractor String; exact handler backend/src/handlers.rs:3675-3704 | Rust return ( StatusCode, Json>, ); exact handler backend/src/handlers.rs:3675-3704 | `df28427afab1724a` | +| `GET` | `/api/notifications/logs` | `getNotificationsLogs` | notifications | notifications | `get_notification_logs_handler` | route+handler+model | none | safe | R0 / query / confirm:none / registeredOperation | query extractor NotificationLogQuery fields [event_type:String, status:String, q:String, start_date:String, end_date:String, limit:i64, offset:i64] (handlers.rs:3607); exact handler backend/src/handlers.rs:3709-3734 | Rust return ( StatusCode, Json>, ); exact handler backend/src/handlers.rs:3709-3734 | `da933f5e679cb9e1` | +| `POST` | `/api/notifications/logs/clear` | `postNotificationsLogsClear` | notifications | notifications | `clear_notification_logs_handler` | route+handler | none | conditional | R3 / job / confirm:strong / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:3737-3760 | Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:3737-3760 | `04de36a4268a3ab6` | +| `GET` | `/api/notifications/queue` | `getNotificationsQueue` | notifications | notifications | `get_notification_queue_handler` | route+handler+model | none | safe | R0 / query / confirm:none / registeredOperation | query extractor NotificationQueueQuery fields [limit:i64] (notification_queue.rs:14); exact handler backend/src/notification_queue.rs:24-41 | Rust return ( StatusCode, Json>, ); exact handler backend/src/notification_queue.rs:24-41 | `babc88061bcb3386` | +| `POST` | `/api/notifications/queue/retry-all` | `postNotificationsQueueRetryAll` | notifications | notifications | `retry_all_notification_queue_handler` | route+handler | none | non-idempotent | R2 / job / confirm:explicit / registeredOperation | none; no body/query/path extractor; exact handler backend/src/notification_queue.rs:84-100 | Rust return (StatusCode, Json>); exact handler backend/src/notification_queue.rs:84-100 | `f58e1be1cbf6a022` | +| `POST` | `/api/notifications/queue/clear` | `postNotificationsQueueClear` | notifications | notifications | `clear_notification_queue_handler` | route+handler | none | conditional | R3 / job / confirm:strong / registeredOperation | none; no body/query/path extractor; exact handler backend/src/notification_queue.rs:103-119 | Rust return (StatusCode, Json>); exact handler backend/src/notification_queue.rs:103-119 | `2501068f6741e02e` | +| `DELETE` | `/api/notifications/queue/{id}` | `deleteNotificationsQueueId` | notifications | notifications | `delete_notification_queue_item_handler` | route+handler | none | idempotent | R3 / job / confirm:strong / registeredOperation | path extractor i64; exact handler backend/src/notification_queue.rs:64-81 | Rust return (StatusCode, Json>); exact handler backend/src/notification_queue.rs:64-81 | `6b845ad2da8f319d` | +| `POST` | `/api/notifications/queue/{id}/retry` | `postNotificationsQueueIdRetry` | notifications | notifications | `retry_notification_queue_item_handler` | route+handler | none | non-idempotent | R2 / job / confirm:explicit / registeredOperation | path extractor i64; exact handler backend/src/notification_queue.rs:44-61 | Rust return (StatusCode, Json>); exact handler backend/src/notification_queue.rs:44-61 | `068be5777b9d8f78` | +| `GET` | `/api/automation/config` | `getAutomationConfig` | automation | automation | `get_automation_config_handler` | route+handler | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:3968-3979 | Rust return ( StatusCode, Json>, ); exact handler backend/src/handlers.rs:3968-3979 | `eed75ffb9545cf5e` | +| `POST` | `/api/automation/config` | `postAutomationConfig` | automation | automation | `set_automation_config_handler` | route+handler+model | application/json | conditional | R2 / job / confirm:explicit / registeredOperation | body extractor crate::config::AutomationConfig fields [enabled:bool, tasks:Vec] (config.rs:1394); exact handler backend/src/handlers.rs:3982-3999 | Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:3982-3999 | `7f324660f7624a52` | +| `GET` | `/api/automation/logs` | `getAutomationLogs` | automation | automation | `get_automation_logs_handler` | route+handler+model | none | safe | R0 / query / confirm:none / registeredOperation | query extractor AutomationLogQuery fields [task_type:String, status:String, q:String, start_date:String, end_date:String, limit:i64, offset:i64] (handlers.rs:3938); exact handler backend/src/handlers.rs:4002-4027 | Rust return ( StatusCode, Json>, ); exact handler backend/src/handlers.rs:4002-4027 | `b6cacc95a374e31a` | +| `POST` | `/api/automation/logs/clear` | `postAutomationLogsClear` | automation | automation | `clear_automation_logs_handler` | route+handler | none | conditional | R3 / job / confirm:strong / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:4030-4053 | Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:4030-4053 | `0912300e74c831ac` | +| `POST` | `/api/automation/test/{task_id}` | `postAutomationTestTaskId` | automation | automation | `test_automation_task_handler` | route+handler | none | non-idempotent | R2 / job / confirm:explicit / registeredOperation | path extractor String; exact handler backend/src/handlers.rs:4056-4146 | Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:4056-4146 | `091db790a51ab185` | +| `GET` | `/api/ota/status` | `getOtaStatus` | ota | ota | `get_ota_status_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:3763-3769 | dynamic-json construction in exact handler backend/src/handlers.rs:3763-3769; field shape is implementation evidence only | `89d8afa94cbad60c` | +| `POST` | `/api/ota/upload` | `postOtaUpload` | ota | ota | `upload_ota_handler` | route+handler | application/octet-stream | non-idempotent | R3 / job / confirm:strong / registeredOperation | bytes extractor axum::body::Bytes; exact exact handler backend/src/handlers.rs:3772-3792 | ApiResponse success payload/error envelope; success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:3772-3792 | `becfb7dc7e9c1355` | +| `POST` | `/api/ota/latest-release` | `postOtaLatestRelease` | ota | ota | `get_latest_ota_release_handler` | route+handler+model | application/json | non-idempotent | R1 / command / confirm:none / registeredOperation | body extractor crate::models::OtaOnlinePrepareRequest fields [proxy_prefix:Option] (models.rs:986); exact handler backend/src/handlers.rs:3795-3825 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:3795-3825 | `61083109860620e5` | +| `POST` | `/api/ota/online-prepare` | `postOtaOnlinePrepare` | ota | ota | `prepare_online_ota_handler` | route+handler+model | application/json | non-idempotent | R2 / job / confirm:explicit / registeredOperation | body extractor crate::models::OtaOnlinePrepareRequest fields [proxy_prefix:Option] (models.rs:986); exact handler backend/src/handlers.rs:3828-3889 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:3828-3889 | `6d70f80714b7f004` | +| `POST` | `/api/ota/apply` | `postOtaApply` | ota | ota | `apply_ota_handler` | route+handler+model+bruno | application/json | non-idempotent | R3 / job / confirm:strong / registeredOperation | body extractor crate::models::OtaApplyRequest fields [restart_now:bool] (models.rs:1030); exact handler backend/src/handlers.rs:3892-3911 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:3892-3911 | `46c24f90fdb71e52` | +| `POST` | `/api/ota/cancel` | `postOtaCancel` | ota | ota | `cancel_ota_handler` | route+handler+bruno | none | conditional | R1 / command / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:3914-3931 | success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:3914-3931 | `9085b81dc3f85886` | +| `POST` | `/api/auth/password` | `postAuthPassword` | instances-auth | settings-instance | `auth::change_password` | route+handler+model | application/json | conditional | R3 / job / confirm:strong / dedicatedFlow | body extractor ChangePasswordRequest fields [new_password:String] (auth.rs:59); exact handler backend/src/auth.rs:491-553 | success/error payload evidenced by ApiResponse; exact handler backend/src/auth.rs:491-553 | `ec7372792586fd2a` | +| `GET` | `/api/auth/settings` | `getAuthSettings` | instances-auth | settings-instance | `auth::get_settings` | route+handler | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/auth.rs:555-570 | Rust return (StatusCode, Json>); exact handler backend/src/auth.rs:555-570 | `035bdb5afbefdcb8` | +| `POST` | `/api/auth/settings` | `postAuthSettings` | instances-auth | settings-instance | `auth::set_settings` | route+handler+model | application/json | conditional | R2 / job / confirm:explicit / dedicatedFlow | body extractor SecurityConfig fields [password_protection_enabled:bool, password_min_length:u8, password_require_letters:bool, password_require_digits:bool, password_require_symbols:bool, session_ttl_seconds:i64, idle_timeout_seconds:i64] (config.rs:615); exact handler backend/src/auth.rs:572-640 | success/error payload evidenced by ApiResponse; exact handler backend/src/auth.rs:572-640 | `10b564c9437defd5` | +| `GET` | `/api/health` | `getHealth` | instances-auth | instances-new | `health_check` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:65-75 | dynamic-json construction in exact handler backend/src/handlers.rs:65-75; field shape is implementation evidence only | `e169391fd5f376b8` | +| `GET` | `/api/auth/status` | `getAuthStatus` | instances-auth | instances-new | `auth::status` | route+handler | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/auth.rs:345-364 | Rust return (StatusCode, Json>); exact handler backend/src/auth.rs:345-364 | `0ee683548f9f8036` | +| `POST` | `/api/auth/setup` | `postAuthSetup` | instances-auth | instances-new | `auth::setup` | route+handler+model | application/json | conditional | R3 / job / confirm:strong / dedicatedFlow | body extractor LoginRequest fields [password:String] (auth.rs:54); exact handler backend/src/auth.rs:366-427 | success/error payload evidenced by ApiResponse; exact handler backend/src/auth.rs:366-427 | `1c0c5eb2cc77326d` | +| `POST` | `/api/auth/login` | `postAuthLogin` | instances-auth | instances-new | `auth::login` | route+handler+model | application/json | conditional | R1 / command / confirm:none / dedicatedFlow | body extractor LoginRequest fields [password:String] (auth.rs:54); exact handler backend/src/auth.rs:429-489 | success/error payload evidenced by ApiResponse; exact handler backend/src/auth.rs:429-489 | `defd9444410e5b12` | +| `POST` | `/api/auth/logout` | `postAuthLogout` | instances-auth | settings-instance | `auth::logout` | route+handler | none | conditional | R1 / command / confirm:none / dedicatedFlow | none body; HeaderMap carries simadmin_session cookie; exact handler backend/src/auth.rs:642-655; cookie name backend/src/auth.rs:43-43 | dynamic-json construction in exact handler backend/src/auth.rs:642-655; field shape is implementation evidence only | `1ccf5c295251b2a2` | + +## Sensitive-field audit ledger + +This table is generated from the registry. Each row is an exact directional redaction contract; evidence details remain machine-readable in the registry. + +| Method | Path | Direction | Sensitive path | Mode | Reason | +|---|---|---|---|---|---| +| GET | `/api/sim` | response | `$.response.data.iccid` | mask | SIM/subscriber identifier | +| GET | `/api/sim` | response | `$.response.data.imsi` | mask | SIM/subscriber identifier | +| GET | `/api/sim` | response | `$.response.data.phone_numbers` | mask | SIM/subscriber identifier | +| GET | `/api/sim` | response | `$.response.data.sms_center` | mask | SIM/subscriber identifier | +| POST | `/api/sim/cache` | request | `$.body.phone_number` | mask | subscriber contact value | +| POST | `/api/sim/cache` | request | `$.body.sms_center` | mask | subscriber contact value | +| GET | `/api/device-network/ddns/config` | response | `$.response.data.access_id` | mask | identifier or address | +| GET | `/api/device-network/ddns/config` | response | `$.response.data.access_secret` | drop | secret or message content | +| POST | `/api/device-network/ddns/config` | request | `$.body.access_id` | mask | identifier or address | +| POST | `/api/device-network/ddns/config` | request | `$.body.access_secret` | drop | secret or message content | +| GET | `/api/device-network/wlan/status` | response | `$.response.data.ssid` | mask | network identifier | +| GET | `/api/device-network/wlan/status` | response | `$.response.data.ipv4_addresses` | mask | network identifier | +| GET | `/api/device-network/wlan/status` | response | `$.response.data.ipv6_addresses` | mask | network identifier | +| POST | `/api/device-network/wlan/scan` | response | `$.response.data.networks[*].ssid` | mask | network identifier | +| POST | `/api/device-network/wlan/scan` | response | `$.response.data.networks[*].bssid` | mask | network identifier | +| GET | `/api/device-network/wlan/profiles` | response | `$.response.data.profiles[*].ssid` | mask | network identifier | +| POST | `/api/device-network/wlan/connect` | request | `$.body.ssid` | mask | network identifier | +| POST | `/api/device-network/wlan/connect` | request | `$.body.password` | drop | WLAN credential | +| GET | `/api/apn` | response | `$.response.data.contexts[*].username` | drop | APN credential | +| GET | `/api/apn` | response | `$.response.data.contexts[*].password` | drop | APN credential | +| POST | `/api/apn` | request | `$.body.username` | mask | identifier or address | +| POST | `/api/apn` | request | `$.body.password` | drop | secret or message content | +| GET | `/api/esim/euicc` | response | `$.response.data.eid` | mask | eUICC identifier | +| GET | `/api/esim/profiles` | response | `$.response.data.profiles[*].iccid` | mask | eSIM identifier | +| POST | `/api/esim/profiles` | request | `$.body.matching_id` | mask | identifier or address | +| POST | `/api/esim/profiles` | request | `$.body.confirmation_code` | drop | secret or message content | +| POST | `/api/esim/profiles` | request | `$.body.imei` | mask | identifier or address | +| GET | `/api/calls` | response | `$.response.data.calls[*].phone_number` | mask | call party | +| POST | `/api/call/dial` | request | `$.body.phone_number` | mask | identifier or address | +| GET | `/api/call/forwarding` | response | `$.response.data.voice_unconditional` | mask | forwarding destination | +| GET | `/api/call/forwarding` | response | `$.response.data.voice_busy` | mask | forwarding destination | +| GET | `/api/call/forwarding` | response | `$.response.data.voice_no_reply` | mask | forwarding destination | +| GET | `/api/call/forwarding` | response | `$.response.data.voice_not_reachable` | mask | forwarding destination | +| POST | `/api/call/forwarding` | request | `$.body.number` | mask | identifier or address | +| GET | `/api/call/history` | response | `$.response.data.records[*].phone_number` | mask | call party | +| GET | `/api/voicemail/status` | response | `$.response.data.mailbox_number` | mask | voicemail number | +| POST | `/api/sms/send` | request | `$.body.phone_number` | mask | identifier or address | +| POST | `/api/sms/send` | request | `$.body.content` | drop | secret or message content | +| GET | `/api/sms/list` | response | `$.response.data.messages[*].phone_number` | mask | message correspondent | +| GET | `/api/sms/list` | response | `$.response.data.messages[*].content` | drop | message body | +| GET | `/api/sms/conversation` | query | `$.query.phone_number` | mask | message correspondent | +| GET | `/api/sms/conversation` | response | `$.response.data.messages[*].phone_number` | mask | message correspondent | +| GET | `/api/sms/conversation` | response | `$.response.data.messages[*].content` | drop | message body | +| POST | `/api/sms/batch-delete` | request | `$.body.phone_numbers[*]` | mask | identifier or address | +| DELETE | `/api/sms/conversation/{phone_number}` | path | `$.path.phone_number` | mask | identifier or address | +| GET | `/api/notifications/config` | response | `$.response.data.channels[*].config` | mask | identifier or address | +| POST | `/api/notifications/config` | request | `$.body.channels[*].config` | mask | identifier or address | +| GET | `/api/automation/config` | response | `$.response.data.tasks[*].config` | mask | identifier or address | +| POST | `/api/automation/config` | request | `$.body.tasks[*].config` | mask | identifier or address | +| POST | `/api/auth/password` | request | `$.body.new_password` | drop | secret or message content | +| POST | `/api/auth/password` | session | `$.session.set-cookie` | drop | secret or message content | +| GET | `/api/auth/settings` | session | `$.session.cookie` | drop | secret or message content | +| POST | `/api/auth/settings` | session | `$.session.cookie` | drop | secret or message content | +| GET | `/api/auth/status` | session | `$.session.cookie` | drop | secret or message content | +| POST | `/api/auth/setup` | request | `$.body.password` | drop | secret or message content | +| POST | `/api/auth/setup` | session | `$.session.set-cookie` | drop | secret or message content | +| POST | `/api/auth/login` | request | `$.body.password` | drop | secret or message content | +| POST | `/api/auth/login` | session | `$.session.set-cookie` | drop | secret or message content | +| POST | `/api/auth/logout` | session | `$.session.cookie` | drop | secret or message content | +| POST | `/api/auth/logout` | session | `$.session.set-cookie` | drop | secret or message content | diff --git a/packages/operation-registry/src/sensitive-evidence-58e2204.ts b/packages/operation-registry/src/sensitive-evidence-58e2204.ts new file mode 100644 index 0000000..dcd1485 --- /dev/null +++ b/packages/operation-registry/src/sensitive-evidence-58e2204.ts @@ -0,0 +1,2491 @@ +/** Generated from independent upstream snapshot model definitions. */ +export const sensitiveModelEvidence58e2204 = { + "Cli": { + "sourceFile": "backend/src/main.rs", + "startLine": 234, + "endLine": 240, + "sourceSha256": "70778b01b0d00c74a1ac9f0b0f28229077e5b88964728f9a6ee1cf269e2fde0a", + "symbol": "Cli", + "fields": {} + }, + "CliCommand": { + "sourceFile": "backend/src/main.rs", + "startLine": 243, + "endLine": 258, + "sourceSha256": "a5c365a39b630ce26f9460d180a82fe97bad1a5253bcb70e3bbc3cdfec62f083", + "symbol": "CliCommand", + "fields": {} + }, + "AuthCommand": { + "sourceFile": "backend/src/main.rs", + "startLine": 261, + "endLine": 266, + "sourceSha256": "57e6e3df9fa26ff31f6056931905416ddf869fca3a4208cfc799bfcb3d6d5829", + "symbol": "AuthCommand", + "fields": {} + }, + "ServeArgs": { + "sourceFile": "backend/src/main.rs", + "startLine": 269, + "endLine": 277, + "sourceSha256": "2c477467e5985ea1fc3a624a0cfd93b2f5e6b4adaa1fa2414567cce838be36f6", + "symbol": "ServeArgs", + "fields": {} + }, + "SystemStatsSamplerState": { + "sourceFile": "backend/src/handlers.rs", + "startLine": 3152, + "endLine": 3160, + "sourceSha256": "fb7aa8ecd48bbc838b9a86fce246749eb22af1e4e8a0e186bd68c3278f4a6b32", + "symbol": "SystemStatsSamplerState", + "fields": {} + }, + "NotificationLogQuery": { + "sourceFile": "backend/src/handlers.rs", + "startLine": 3607, + "endLine": 3622, + "sourceSha256": "37621ba31c70d9e5ec5e3a2b2ac6fcf4467ab1c404445e17708816d43e3fc62b", + "symbol": "NotificationLogQuery", + "fields": { + "event_type": "String", + "status": "String", + "q": "String", + "start_date": "String", + "end_date": "String", + "limit": "i64", + "offset": "i64" + } + }, + "NotificationLogClearRequest": { + "sourceFile": "backend/src/handlers.rs", + "startLine": 3625, + "endLine": 3634, + "sourceSha256": "e2ba598bffc8493b7a94a34036bf1e28cbfda491a5d05ae0308833524f5067c6", + "symbol": "NotificationLogClearRequest", + "fields": { + "event_type": "String", + "status": "String", + "start_date": "String", + "end_date": "String" + } + }, + "AutomationLogQuery": { + "sourceFile": "backend/src/handlers.rs", + "startLine": 3938, + "endLine": 3953, + "sourceSha256": "60ffd4c72f57c22da74839546536006420dc0d773d864ee0bd96461e22e7bf91", + "symbol": "AutomationLogQuery", + "fields": { + "task_type": "String", + "status": "String", + "q": "String", + "start_date": "String", + "end_date": "String", + "limit": "i64", + "offset": "i64" + } + }, + "AutomationLogClearRequest": { + "sourceFile": "backend/src/handlers.rs", + "startLine": 3956, + "endLine": 3965, + "sourceSha256": "ac6557089b3527ab8cdf50294c881aa60b191e5c3264186331bd004b335a42c8", + "symbol": "AutomationLogClearRequest", + "fields": { + "task_type": "String", + "status": "String", + "start_date": "String", + "end_date": "String" + } + }, + "LoginRequest": { + "sourceFile": "backend/src/auth.rs", + "startLine": 54, + "endLine": 56, + "sourceSha256": "a333d129da6264b0c84443151fff194aaf69b1c4d1889f6044f3acb8aa0e3536", + "symbol": "LoginRequest", + "fields": { + "password": "String" + } + }, + "ChangePasswordRequest": { + "sourceFile": "backend/src/auth.rs", + "startLine": 59, + "endLine": 61, + "sourceSha256": "b948627546180642fbd2298ec4aed4a2a76d8bab8d8252dcef2058fd410cc904", + "symbol": "ChangePasswordRequest", + "fields": { + "new_password": "String" + } + }, + "AuthStatusResponse": { + "sourceFile": "backend/src/auth.rs", + "startLine": 64, + "endLine": 68, + "sourceSha256": "155fbb4f48fd81dbf141abb81a3e9b74b8aea1299f174b5b147353ba8a1aa388", + "symbol": "AuthStatusResponse", + "fields": { + "configured": "bool", + "authenticated": "bool", + "settings": "SecurityConfig" + } + }, + "AuthSettingsResponse": { + "sourceFile": "backend/src/auth.rs", + "startLine": 71, + "endLine": 74, + "sourceSha256": "5b0146ad305d3c4cd51ec61ae6950dd8d70ae593f39bb5051fcc112a426d80e4", + "symbol": "AuthSettingsResponse", + "fields": { + "configured": "bool", + "settings": "SecurityConfig" + } + }, + "SessionToken": { + "sourceFile": "backend/src/auth.rs", + "startLine": 77, + "endLine": 80, + "sourceSha256": "6f4b31df7649517a9d244b71253f5ba7595bc92ba03690851034c6e0f85b098e", + "symbol": "SessionToken", + "fields": {} + }, + "ApiResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 9, + "endLine": 14, + "sourceSha256": "e0f2d3f1560ee9e5557d524a7a567e66e542fca9a2de090301a5b943bdeb12f9", + "symbol": "ApiResponse", + "fields": { + "status": "String", + "message": "String", + "data": "Option" + } + }, + "WorkMode": { + "sourceFile": "backend/src/models.rs", + "startLine": 41, + "endLine": 45, + "sourceSha256": "b8b9a903e70a94ef2d87905e388a775de52f40e7d55ffb9e6338c1db0c29a951", + "symbol": "WorkMode", + "fields": {} + }, + "WorkModeRequest": { + "sourceFile": "backend/src/models.rs", + "startLine": 57, + "endLine": 61, + "sourceSha256": "6f994606974288f554e5e29ba4b4099604fdae92762bb8f0a38fc6b4c1db69ec", + "symbol": "WorkModeRequest", + "fields": { + "mode": "WorkMode", + "confirm": "bool" + } + }, + "WorkModeResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 64, + "endLine": 67, + "sourceSha256": "c4576a3cc78aec3e1563090898ed107850fa6f0aaeb9ebf1d8b454cc987c9532", + "symbol": "WorkModeResponse", + "fields": { + "mode": "WorkMode", + "worker_running": "bool" + } + }, + "EsimCommandResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 70, + "endLine": 81, + "sourceSha256": "c812a61e3ab89df77bea2168552f96f404bdde64d292e780c446ad0e8454ac23", + "symbol": "EsimCommandResponse", + "fields": { + "code": "i32", + "status": "String", + "action": "String", + "msg": "String", + "data": "Option" + } + }, + "EsimEuiccInfo": { + "sourceFile": "backend/src/models.rs", + "startLine": 84, + "endLine": 98, + "sourceSha256": "7794ac6126743cf5382ec034ce4e795c9b89f6fd7512bc0e67aa78da914f2ca5", + "symbol": "EsimEuiccInfo", + "fields": { + "eid": "String", + "status": "String", + "manufacturer": "String", + "memory_total_kb": "Option", + "memory_available_kb": "Option", + "memory_total_customizable": "Option", + "updated_at": "Option", + "raw": "Value" + } + }, + "EsimProfile": { + "sourceFile": "backend/src/models.rs", + "startLine": 101, + "endLine": 132, + "sourceSha256": "41fd6bf48dbec7def858bb816d7727e6b908ee956078570d1ac9b40a1b37100b", + "symbol": "EsimProfile", + "fields": { + "iccid": "String", + "name": "String", + "provider": "String", + "state": "String", + "profile_class": "String", + "imsi": "Option", + "msisdn": "Option", + "smsc": "Option", + "smdp": "Option", + "matching_id": "Option", + "isdp_aid": "Option", + "mcc": "Option", + "mnc": "Option", + "disable_allowed": "Option", + "delete_allowed": "Option", + "updated_at": "Option", + "raw": "Value" + } + }, + "EsimProfilesResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 135, + "endLine": 137, + "sourceSha256": "1f5b824bc368e322dee089bb37e400598ba9a3432499c115c29e3471e557db5d", + "symbol": "EsimProfilesResponse", + "fields": { + "profiles": "Vec" + } + }, + "EsimLpacStatusResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 140, + "endLine": 150, + "sourceSha256": "e0c6e6114f11eb93d8046ab67763b6b94f9fceeee26b328cf4edd5e571f3c9da", + "symbol": "EsimLpacStatusResponse", + "fields": { + "installed": "bool", + "usable": "bool", + "path": "String", + "arch": "String", + "glibc_version": "String", + "asset_name": "String", + "message": "String", + "source": "Option" + } + }, + "EsimLpacRepairRequest": { + "sourceFile": "backend/src/models.rs", + "startLine": 153, + "endLine": 156, + "sourceSha256": "0345aa3f1d0dce217ddbedcd8776a2d5a774a6dd3c49790eac53c93b59f3296d", + "symbol": "EsimLpacRepairRequest", + "fields": { + "proxy_prefix": "Option", + "asset_url": "Option" + } + }, + "EsimLpacRepairResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 159, + "endLine": 166, + "sourceSha256": "6c42d57d23392ddfa1ea8ee207ccd6aae3c5e4c688b5aa2f5a547419dbf05088", + "symbol": "EsimLpacRepairResponse", + "fields": { + "installed": "bool", + "path": "String", + "arch": "String", + "asset_name": "String", + "asset_url": "String", + "message": "String" + } + }, + "EsimRenameRequest": { + "sourceFile": "backend/src/models.rs", + "startLine": 169, + "endLine": 171, + "sourceSha256": "1d610219329178bec27b005b52357e618b3684be48080d3f7b6d726b1146a413", + "symbol": "EsimRenameRequest", + "fields": { + "name": "String" + } + }, + "EsimDownloadRequest": { + "sourceFile": "backend/src/models.rs", + "startLine": 174, + "endLine": 179, + "sourceSha256": "c289cfd6af916a1a31987a4b6d2f4012029f40e64cd9a845bb47ab562af93c37", + "symbol": "EsimDownloadRequest", + "fields": { + "smdp": "String", + "matching_id": "String", + "confirmation_code": "Option", + "imei": "Option" + } + }, + "ServingCell": { + "sourceFile": "backend/src/models.rs", + "startLine": 182, + "endLine": 186, + "sourceSha256": "6ac6afb21a6467b8b50fcf3102a6a00b73f70b09f1706760108680d580b5f58a", + "symbol": "ServingCell", + "fields": { + "tech": "String", + "cell_id": "u32", + "tac": "u32" + } + }, + "CellInfo": { + "sourceFile": "backend/src/models.rs", + "startLine": 189, + "endLine": 213, + "sourceSha256": "42c1e43e6c6847df3fbc3ebc6e1a1b2378b81a38b2c52ac6115f08794103fff3", + "symbol": "CellInfo", + "fields": { + "is_serving": "bool", + "tech": "String", + "cell_id": "u32", + "band": "String", + "arfcn": "String", + "pci": "String", + "rsrp": "String", + "rsrq": "String", + "sinr": "String", + "earfcn": "String", + "nrarfcn": "String", + "cell_type": "String", + "ssb_rsrp": "String", + "ssb_rsrq": "String", + "ssb_sinr": "String" + } + }, + "CellsResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 216, + "endLine": 220, + "sourceSha256": "0d100dcc32419e35c6307d682392aee9da6cfb709f7c0a41fd444f5fea806a4a", + "symbol": "CellsResponse", + "fields": { + "serving_cell": "ServingCell", + "cells": "Vec" + } + }, + "DeviceInfoResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 223, + "endLine": 231, + "sourceSha256": "dcd68ecf845280d6c19779a2e9617c4d0ec87b1823b0c45c072139f59556c5c4", + "symbol": "DeviceInfoResponse", + "fields": { + "imei": "String", + "manufacturer": "String", + "model": "String", + "revision": "Option", + "online": "bool", + "powered": "bool" + } + }, + "DataConnectionRequest": { + "sourceFile": "backend/src/models.rs", + "startLine": 234, + "endLine": 236, + "sourceSha256": "5809e9d26c512e308d32f835756449f923f44198f130fe1f805a982539fba68a", + "symbol": "DataConnectionRequest", + "fields": { + "active": "bool" + } + }, + "DataConnectionResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 239, + "endLine": 241, + "sourceSha256": "a2090a109ebd433d392959b49f7af25292962db4a7c6e3e9aaa3ec232cfff5f3", + "symbol": "DataConnectionResponse", + "fields": { + "active": "bool" + } + }, + "RoamingRequest": { + "sourceFile": "backend/src/models.rs", + "startLine": 244, + "endLine": 246, + "sourceSha256": "f3bc73ca411e378ba0ca12fa9bcfc44ca562af86f83d51f56121f30081814703", + "symbol": "RoamingRequest", + "fields": { + "allowed": "bool" + } + }, + "RoamingResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 249, + "endLine": 252, + "sourceSha256": "e40afb67d33b552ca2e1031ff3577e6144bee66d845fc957875f3ef9cd601140", + "symbol": "RoamingResponse", + "fields": { + "roaming_allowed": "bool", + "is_roaming": "bool" + } + }, + "AirplaneModeRequest": { + "sourceFile": "backend/src/models.rs", + "startLine": 255, + "endLine": 257, + "sourceSha256": "5b01ca2ade4b410d12cc9cfe64564d8e29c6f47a1f2357800522cb8f9101134f", + "symbol": "AirplaneModeRequest", + "fields": { + "enabled": "bool" + } + }, + "AirplaneModeResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 260, + "endLine": 264, + "sourceSha256": "d93cec1a6fe1fc38a8fc2257580f16d2624e2d2ee68971116d6d5065a5feae2a", + "symbol": "AirplaneModeResponse", + "fields": { + "enabled": "bool", + "powered": "bool", + "online": "bool" + } + }, + "BasebandRestartStep": { + "sourceFile": "backend/src/models.rs", + "startLine": 267, + "endLine": 272, + "sourceSha256": "2e4f3cea6e19986391ddda87a38f929d55c1ad72c3fdbe3054dada941dd5f046", + "symbol": "BasebandRestartStep", + "fields": { + "step": "String", + "status": "String", + "detail": "Option" + } + }, + "BasebandRestartResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 275, + "endLine": 280, + "sourceSha256": "ea551f32f5cc5ebc3d46eb12cab65c2ae1d0a70c32764e4a0e8ad5ade691705e", + "symbol": "BasebandRestartResponse", + "fields": { + "steps": "Vec", + "running": "bool", + "current_registration": "Option" + } + }, + "ThermalZone": { + "sourceFile": "backend/src/models.rs", + "startLine": 283, + "endLine": 289, + "sourceSha256": "b40b140664f34027b04355407d69f7a7e70a0a992299304b224b54e70670142f", + "symbol": "ThermalZone", + "fields": { + "zone": "String", + "sensor_type": "String", + "label": "String", + "temperature": "f64" + } + }, + "SimInfoResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 292, + "endLine": 319, + "sourceSha256": "3740641a14b199ffdbdabc1e5c6dc1ef1a1e68fbbfedb81800194fdd1d1e77b1", + "symbol": "SimInfoResponse", + "fields": { + "present": "bool", + "iccid": "String", + "imsi": "String", + "phone_numbers": "Vec", + "sms_center": "String", + "mcc": "String", + "mnc": "String", + "phone_number_is_manual": "bool", + "sms_center_is_manual": "bool", + "sim_path": "String", + "modem_path": "String", + "sim_type": "String", + "esim_status": "String", + "active": "bool", + "operator_name": "String", + "registered_operator_name": "String", + "registered_operator_code": "String", + "lock_status": "String", + "pin1_retries": "Option", + "puk1_retries": "Option", + "pin2_retries": "Option", + "puk2_retries": "Option", + "carrier_config": "String", + "carrier_config_revision": "String", + "sms_used": "Option", + "sms_total": "Option" + } + }, + "NetworkInfoResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 322, + "endLine": 331, + "sourceSha256": "db4bc775f1957f9beda261e2d5f0dadb8736e48bfe9fa46c9938c51cf0622ca5", + "symbol": "NetworkInfoResponse", + "fields": { + "operator_name": "String", + "registration_status": "String", + "technology_preference": "String", + "signal_strength": "u8", + "mcc": "Option", + "mnc": "Option" + } + }, + "RadioMode": { + "sourceFile": "backend/src/models.rs", + "startLine": 335, + "endLine": 341, + "sourceSha256": "c92e023549f6c3b8e90b876352c37af8f27fca4a2703475de13225cdbf274553", + "symbol": "RadioMode", + "fields": {} + }, + "RadioModeResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 344, + "endLine": 348, + "sourceSha256": "ee2c006cc5e1a03e0bc8d1b720a80ba9dd0b0a4e65725349af49bf9fbc26dddf", + "symbol": "RadioModeResponse", + "fields": { + "mode": "String", + "technology_preference": "String", + "supported_modes": "Vec" + } + }, + "RadioModeRequest": { + "sourceFile": "backend/src/models.rs", + "startLine": 351, + "endLine": 353, + "sourceSha256": "826ab90aeb4cf5c0c89d98b6cc75f922938ba42d10a264c913532bc50420419e", + "symbol": "RadioModeRequest", + "fields": { + "mode": "RadioMode" + } + }, + "BandLockStatus": { + "sourceFile": "backend/src/models.rs", + "startLine": 356, + "endLine": 374, + "sourceSha256": "ee0a2e9cd4fd25858a753b9e28566d78e5cf85b4135619ee7074346ce54e9cb9", + "symbol": "BandLockStatus", + "fields": { + "locked": "bool", + "supported_lte_fdd_bands": "Vec", + "supported_lte_tdd_bands": "Vec", + "supported_nr_fdd_bands": "Vec", + "supported_nr_tdd_bands": "Vec", + "lte_fdd_bands": "Vec", + "lte_tdd_bands": "Vec", + "nr_fdd_bands": "Vec", + "nr_tdd_bands": "Vec" + } + }, + "BandLockRequest": { + "sourceFile": "backend/src/models.rs", + "startLine": 377, + "endLine": 386, + "sourceSha256": "690b44d489734f4db1daa819e25099b2e1fbc1982c45d2a08a25bd6ab569235c", + "symbol": "BandLockRequest", + "fields": { + "lte_fdd_bands": "Vec", + "lte_tdd_bands": "Vec", + "nr_fdd_bands": "Vec", + "nr_tdd_bands": "Vec" + } + }, + "CellLockRatStatus": { + "sourceFile": "backend/src/models.rs", + "startLine": 389, + "endLine": 396, + "sourceSha256": "6496254b63ffcc1a7c1c4e97caf6cafebe01c2933fdc2f483566c8bff1b1366c", + "symbol": "CellLockRatStatus", + "fields": { + "rat": "u8", + "rat_name": "String", + "enabled": "bool", + "lock_type": "u8", + "pci": "Option", + "arfcn": "Option" + } + }, + "CellLockStatusResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 399, + "endLine": 402, + "sourceSha256": "e117edbe0943f42746c7fece5a9a29f27af13e9d43ec219e644b697d5b23f1d8", + "symbol": "CellLockStatusResponse", + "fields": { + "rat_status": "Vec", + "any_locked": "bool" + } + }, + "CellLockRequest": { + "sourceFile": "backend/src/models.rs", + "startLine": 405, + "endLine": 415, + "sourceSha256": "ce2683159c40e8c37e15185daca3308065d4d4be372393bfb7ea14cedec39520", + "symbol": "CellLockRequest", + "fields": { + "rat": "u8", + "enable": "bool", + "lock_type": "u8", + "pci": "Option", + "arfcn": "Option" + } + }, + "SystemRebootRequest": { + "sourceFile": "backend/src/models.rs", + "startLine": 422, + "endLine": 425, + "sourceSha256": "a619d31a2a25801c7160c8e7e7e044b75c416345782468eb282f9c1de09fa3ec", + "symbol": "SystemRebootRequest", + "fields": { + "delay_seconds": "u32" + } + }, + "NetworkSpeed": { + "sourceFile": "backend/src/models.rs", + "startLine": 428, + "endLine": 434, + "sourceSha256": "97cec0d6b970a30fdbecfdd60544a9f4653da50583d4bf852622d57c855407f0", + "symbol": "NetworkSpeed", + "fields": { + "interface": "String", + "rx_bytes_per_sec": "u64", + "tx_bytes_per_sec": "u64", + "total_rx_bytes": "u64", + "total_tx_bytes": "u64" + } + }, + "NetworkSpeedResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 437, + "endLine": 440, + "sourceSha256": "6d71ba54aa3292fde6b6b964d2bf722825e05e85c6b6331545c85a8f2375034d", + "symbol": "NetworkSpeedResponse", + "fields": { + "interfaces": "Vec", + "interval_seconds": "f64" + } + }, + "MemoryInfo": { + "sourceFile": "backend/src/models.rs", + "startLine": 443, + "endLine": 450, + "sourceSha256": "39d0af92732acacf5fcb458209c9f4cbcaddd196f874dc39306ebde45a08e1cc", + "symbol": "MemoryInfo", + "fields": { + "total_bytes": "u64", + "available_bytes": "u64", + "used_bytes": "u64", + "used_percent": "f64", + "cached_bytes": "u64", + "buffers_bytes": "u64" + } + }, + "UptimeInfo": { + "sourceFile": "backend/src/models.rs", + "startLine": 453, + "endLine": 457, + "sourceSha256": "d2d289c706a08bf5de86914f66f4f79831dfc41e89e3a27002b58e4e24137b4f", + "symbol": "UptimeInfo", + "fields": { + "uptime_seconds": "u64", + "idle_seconds": "u64", + "uptime_formatted": "String" + } + }, + "SystemInfo": { + "sourceFile": "backend/src/models.rs", + "startLine": 460, + "endLine": 469, + "sourceSha256": "bbd92d6e469584f53498c0ac7e09bee74388a4810b97ad3d2b71ea2729307e5a", + "symbol": "SystemInfo", + "fields": { + "sysname": "String", + "nodename": "String", + "release": "String", + "version": "String", + "machine": "String", + "domainname": "String", + "full_info": "String" + } + }, + "SystemStatsResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 472, + "endLine": 480, + "sourceSha256": "682ed5b75883b481243cd82eadee3b01cdb86421959697ee437458dec576a225", + "symbol": "SystemStatsResponse", + "fields": { + "network_speed": "NetworkSpeedResponse", + "memory": "MemoryInfo", + "disk": "Vec", + "cpu_load": "CpuLoadInfo", + "uptime": "UptimeInfo", + "system_info": "SystemInfo", + "temperature": "Vec" + } + }, + "DiskInfo": { + "sourceFile": "backend/src/models.rs", + "startLine": 483, + "endLine": 490, + "sourceSha256": "33c485eb04937e8e7c95cdffbdc1c3d3c021e2df7d08fe79cb3a83ecf66f60f8", + "symbol": "DiskInfo", + "fields": { + "mount_point": "String", + "fs_type": "String", + "total_bytes": "u64", + "used_bytes": "u64", + "available_bytes": "u64", + "used_percent": "f64" + } + }, + "PingResult": { + "sourceFile": "backend/src/models.rs", + "startLine": 493, + "endLine": 498, + "sourceSha256": "5369f44bbed7a94bca9b62949861b35e952a67f3844c9f679a7a6daa7a2453d1", + "symbol": "PingResult", + "fields": { + "success": "bool", + "latency_ms": "Option", + "target": "String", + "error": "Option" + } + }, + "ConnectivityCheckResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 501, + "endLine": 504, + "sourceSha256": "67f9945179b4738841849a49ab5dc4cf0c60cf6ff89efe261c8fd1946e055ffc", + "symbol": "ConnectivityCheckResponse", + "fields": { + "ipv4": "PingResult", + "ipv6": "PingResult" + } + }, + "DdnsStatusResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 507, + "endLine": 515, + "sourceSha256": "e422f769ad62b8390d0b1efeee23c997e333ccda85a9ab4333d3d06e1baf143b", + "symbol": "DdnsStatusResponse", + "fields": { + "enabled": "bool", + "running": "bool", + "provider": "String", + "last_sync_at": "Option", + "last_ipv4": "Option", + "last_ipv6": "Option", + "last_message": "Option" + } + }, + "DdnsLogEntry": { + "sourceFile": "backend/src/models.rs", + "startLine": 518, + "endLine": 524, + "sourceSha256": "7092cce8efdc96f89d07b1726bcd5bb1e479cd78eff70c8d48267dc14793455e", + "symbol": "DdnsLogEntry", + "fields": { + "timestamp": "String", + "level": "String", + "record_type": "String", + "domains": "Vec", + "message": "String" + } + }, + "DdnsLogsResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 527, + "endLine": 529, + "sourceSha256": "a0406c2266570dfba6ef35a26b52b75d853fb2b1a2d8bd4013f1e0aede5d3ac8", + "symbol": "DdnsLogsResponse", + "fields": { + "entries": "Vec" + } + }, + "DdnsRecordSyncResult": { + "sourceFile": "backend/src/models.rs", + "startLine": 532, + "endLine": 539, + "sourceSha256": "ede95e045b1f57f76f12dbd1241b122e293ba734726ac8df7a2aadd1795b4c55", + "symbol": "DdnsRecordSyncResult", + "fields": { + "record_type": "String", + "domains": "Vec", + "old_ip": "Option", + "new_ip": "Option", + "status": "String", + "message": "String" + } + }, + "DdnsSyncResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 542, + "endLine": 546, + "sourceSha256": "680749e91404c508babd10a2387399322dbc2a0e87c1ef823ead3cf93f11dae4", + "symbol": "DdnsSyncResponse", + "fields": { + "started_at": "String", + "finished_at": "String", + "records": "Vec" + } + }, + "DdnsEvent": { + "sourceFile": "backend/src/models.rs", + "startLine": 549, + "endLine": 559, + "sourceSha256": "7924a945fd98130d2b17c5715c1fe8c0885da02fbf8dc629df2174e69eca3ea7", + "symbol": "DdnsEvent", + "fields": { + "provider": "String", + "record_type": "String", + "domains": "Vec", + "old_ip": "Option", + "new_ip": "Option", + "status": "String", + "message": "String", + "timestamp": "String", + "failure_count": "u32" + } + }, + "WlanStatusResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 562, + "endLine": 573, + "sourceSha256": "a4c12c012b2970de39959bf796925d07af17fca6042c8eeef59ef7aa24e2c242", + "symbol": "WlanStatusResponse", + "fields": { + "available": "bool", + "enabled": "bool", + "hardware_enabled": "bool", + "interface_name": "Option", + "connected": "bool", + "ssid": "Option", + "connection_id": "Option", + "ipv4_addresses": "Vec", + "ipv4_gateway": "Option", + "ipv6_addresses": "Vec" + } + }, + "WlanNetwork": { + "sourceFile": "backend/src/models.rs", + "startLine": 576, + "endLine": 583, + "sourceSha256": "f7d43df0a87fc6d039b26f327b3a13fa69badba3b39a1a5a63c3edb60fe6e03b", + "symbol": "WlanNetwork", + "fields": { + "ssid": "String", + "bssid": "String", + "signal": "u8", + "security": "String", + "secure": "bool", + "connected": "bool" + } + }, + "WlanScanResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 586, + "endLine": 588, + "sourceSha256": "18425a0d1c1c473c1628f45fab8617974434b6245ebf2f42752cc0a1f6400c38", + "symbol": "WlanScanResponse", + "fields": { + "networks": "Vec" + } + }, + "WlanSavedNetwork": { + "sourceFile": "backend/src/models.rs", + "startLine": 591, + "endLine": 598, + "sourceSha256": "05ae1fbf946153818bae5aace432d3e3aa4cfd6027e7420536ca8694ad21616a", + "symbol": "WlanSavedNetwork", + "fields": { + "id": "String", + "uuid": "String", + "ssid": "String", + "interface_name": "Option", + "active": "bool", + "auto_join": "bool" + } + }, + "WlanProfilesResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 601, + "endLine": 603, + "sourceSha256": "9da2806f9e2d7c02c0c82a269d45922abc19c6a355c51bd6fbdeec27f6ee6561", + "symbol": "WlanProfilesResponse", + "fields": { + "profiles": "Vec" + } + }, + "WlanEnabledRequest": { + "sourceFile": "backend/src/models.rs", + "startLine": 606, + "endLine": 608, + "sourceSha256": "5b68debfd6293c67d0856dfb87552014e0cbaf0f24d88f2e99b48743602f03b1", + "symbol": "WlanEnabledRequest", + "fields": { + "enabled": "bool" + } + }, + "WlanConnectRequest": { + "sourceFile": "backend/src/models.rs", + "startLine": 611, + "endLine": 617, + "sourceSha256": "28653f519f8f46a5d48fc60c1a90b51a13da9bb216b506131a5b67ce669e136e", + "symbol": "WlanConnectRequest", + "fields": { + "ssid": "String", + "password": "String", + "auto_join": "bool" + } + }, + "WlanProfileRequest": { + "sourceFile": "backend/src/models.rs", + "startLine": 620, + "endLine": 632, + "sourceSha256": "4b3c4daf6c86f0ed1dc03fd00bd233de221d53b318bb7c1e1ac04024fff88c8f", + "symbol": "WlanProfileRequest", + "fields": { + "connection_id": "String", + "auto_join": "Option", + "ipv4_mode": "Option", + "ipv4_address": "Option", + "ipv4_prefix": "Option", + "ipv4_gateway": "Option" + } + }, + "WlanForgetRequest": { + "sourceFile": "backend/src/models.rs", + "startLine": 635, + "endLine": 640, + "sourceSha256": "8cf9de418699e5d2ef5646157b130c1e661ee4a99e0bf55c5aeea296e7b8284c", + "symbol": "WlanForgetRequest", + "fields": { + "uuid": "String", + "connection_id": "String" + } + }, + "CpuLoadInfo": { + "sourceFile": "backend/src/models.rs", + "startLine": 647, + "endLine": 653, + "sourceSha256": "72dae8241f3cb785f631d61e1ee61d940dc5e2a02b3c859a860ef2b6c8cd19a8", + "symbol": "CpuLoadInfo", + "fields": { + "load_1min": "f64", + "load_5min": "f64", + "load_15min": "f64", + "core_count": "u32", + "load_percent": "f64" + } + }, + "CpuCore": { + "sourceFile": "backend/src/models.rs", + "startLine": 656, + "endLine": 665, + "sourceSha256": "2a41d32c239292653118ab6315d4e4dad7fdb0eea030ecdbe6e4b2ff397d49d1", + "symbol": "CpuCore", + "fields": { + "processor": "u32", + "bogomips": "String", + "features": "Vec", + "implementer": "String", + "architecture": "String", + "variant": "String", + "part": "String", + "revision": "String" + } + }, + "CpuInfo": { + "sourceFile": "backend/src/models.rs", + "startLine": 668, + "endLine": 674, + "sourceSha256": "18f0a5679fc0be1579e6feb2087ce42d2a39de98d64e48d2a2026e0b5fab5b4e", + "symbol": "CpuInfo", + "fields": { + "core_count": "u32", + "cores": "Vec", + "hardware": "String", + "serial": "String", + "model_name": "String" + } + }, + "IpAddress": { + "sourceFile": "backend/src/models.rs", + "startLine": 677, + "endLine": 682, + "sourceSha256": "0262881162bf55fb090785ad95994d4607fc7b28ce8e7f332cfe5064c85bf57d", + "symbol": "IpAddress", + "fields": { + "address": "String", + "prefix_len": "u8", + "ip_type": "String", + "scope": "String" + } + }, + "NetworkInterfaceInfo": { + "sourceFile": "backend/src/models.rs", + "startLine": 685, + "endLine": 702, + "sourceSha256": "fcd5e1d5a4a2273973171232dc5830564669063013b18aeb80e42160db360206", + "symbol": "NetworkInterfaceInfo", + "fields": { + "name": "String", + "status": "String", + "is_wireless": "bool", + "is_cellular": "bool", + "is_default_ipv4": "bool", + "is_default_ipv6": "bool", + "mac_address": "Option", + "mtu": "u32", + "ip_addresses": "Vec", + "rx_bytes": "u64", + "tx_bytes": "u64", + "rx_packets": "u64", + "tx_packets": "u64", + "rx_errors": "u64", + "tx_errors": "u64" + } + }, + "NetworkInterfacesResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 705, + "endLine": 708, + "sourceSha256": "b9788a7f2f5135cfe2114c280172286d65d9f8c44dcb78bb8ccf4728341d0dc1", + "symbol": "NetworkInterfacesResponse", + "fields": { + "interfaces": "Vec", + "total_count": "usize" + } + }, + "ConnectionAddressesResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 711, + "endLine": 718, + "sourceSha256": "79a8ad8d4e3eea5ea329c5e7cefa2e9518953b0645bae367d04c2fb596118c57", + "symbol": "ConnectionAddressesResponse", + "fields": { + "ipv4": "Vec", + "ipv6": "Vec", + "ipv4_interface": "Option", + "ipv6_interface": "Option" + } + }, + "SignalStrengthResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 721, + "endLine": 723, + "sourceSha256": "5bbc4951dfb85cf410e11e90929846fa63974f2d083846f1a7a4b45a1078d038", + "symbol": "SignalStrengthResponse", + "fields": { + "strength": "i32" + } + }, + "CellLocationInfo": { + "sourceFile": "backend/src/models.rs", + "startLine": 726, + "endLine": 741, + "sourceSha256": "0e3fc2afd738791179615f929e9020e2958cc77d4cdd355495a746e86684aa3b", + "symbol": "CellLocationInfo", + "fields": { + "mcc": "String", + "mnc": "String", + "lac": "u32", + "cid": "u32", + "signal_strength": "i32", + "radio_type": "String", + "arfcn": "Option", + "pci": "Option", + "rsrq": "Option", + "sinr": "Option" + } + }, + "CellLocationResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 744, + "endLine": 752, + "sourceSha256": "3eb9561a78d76b8276ea681f54e3ab24c51df1c7299cfb287e5c31c33032b49c", + "symbol": "CellLocationResponse", + "fields": { + "available": "bool", + "cell_info": "Option", + "neighbor_cells": "Vec", + "cells": "Option>" + } + }, + "OperatorInfo": { + "sourceFile": "backend/src/models.rs", + "startLine": 755, + "endLine": 763, + "sourceSha256": "49f683028215277b66c029806877c372b6d6b06e1ab0209103b94d8094ae5cc0", + "symbol": "OperatorInfo", + "fields": { + "path": "String", + "name": "String", + "status": "String", + "mcc": "String", + "mnc": "String", + "technologies": "Vec" + } + }, + "OperatorListResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 766, + "endLine": 768, + "sourceSha256": "927df0e09ca10d0744fe1c8a65e9e0c5a8d257736898625c08d299eeb21248af", + "symbol": "OperatorListResponse", + "fields": { + "operators": "Vec" + } + }, + "ManualRegisterRequest": { + "sourceFile": "backend/src/models.rs", + "startLine": 771, + "endLine": 773, + "sourceSha256": "10db81d311c2a9f28710d5f5ef40204ec287cbca1e47e7c211e8b7b064e844b8", + "symbol": "ManualRegisterRequest", + "fields": { + "mccmnc": "String" + } + }, + "ApnContext": { + "sourceFile": "backend/src/models.rs", + "startLine": 776, + "endLine": 787, + "sourceSha256": "c629775a4e5c58b08db515f20b877796842cdeea8607285248f73cee3e6046eb", + "symbol": "ApnContext", + "fields": { + "path": "String", + "name": "String", + "active": "bool", + "apn": "String", + "protocol": "String", + "username": "String", + "password": "String", + "auth_method": "String", + "context_type": "String" + } + }, + "ApnListResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 790, + "endLine": 792, + "sourceSha256": "3e67a492b7a83e2471c28d68d8e194f69802301e4efc237a23878f34fce92a57", + "symbol": "ApnListResponse", + "fields": { + "contexts": "Vec" + } + }, + "SetApnRequest": { + "sourceFile": "backend/src/models.rs", + "startLine": 795, + "endLine": 802, + "sourceSha256": "b5732eca064eddf8a084ba3661004ce583150710217a7f2097926456922a0295", + "symbol": "SetApnRequest", + "fields": { + "context_path": "String", + "apn": "Option", + "protocol": "Option", + "username": "Option", + "password": "Option", + "auth_method": "Option" + } + }, + "CellLockResult": { + "sourceFile": "backend/src/models.rs", + "startLine": 805, + "endLine": 807, + "sourceSha256": "8c645d5c9c9cc61d42654ad34d56885f7bb3f9718ad40995107df7dd34b8445b", + "symbol": "CellLockResult", + "fields": { + "success": "bool" + } + }, + "MakeCallRequest": { + "sourceFile": "backend/src/models.rs", + "startLine": 810, + "endLine": 812, + "sourceSha256": "050178afd522565f4befc4dbe7934efc8dd3ff5588a5b149a70e9b3bfa991b2d", + "symbol": "MakeCallRequest", + "fields": { + "phone_number": "String" + } + }, + "CallInfo": { + "sourceFile": "backend/src/models.rs", + "startLine": 815, + "endLine": 822, + "sourceSha256": "10ca4cb65a44580602f387dca5dbb43ff86d381f7e01b7fa9069ca8590ba2af2", + "symbol": "CallInfo", + "fields": { + "path": "String", + "phone_number": "String", + "state": "String", + "direction": "String", + "start_time": "Option" + } + }, + "CallListResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 825, + "endLine": 827, + "sourceSha256": "534ffadc277a3a9cef22e72c7749573ae775f41011220cdc4fa4779266ce713a", + "symbol": "CallListResponse", + "fields": { + "calls": "Vec" + } + }, + "HangupCallRequest": { + "sourceFile": "backend/src/models.rs", + "startLine": 830, + "endLine": 832, + "sourceSha256": "b405a57b92217e0b123abbd6316207a8df9422d90c7178e997e65414fbfcf7be", + "symbol": "HangupCallRequest", + "fields": { + "path": "String" + } + }, + "CallHistoryRequest": { + "sourceFile": "backend/src/models.rs", + "startLine": 835, + "endLine": 840, + "sourceSha256": "a51160efe9c06aa423a4d7cfa63a5d6a14bc703dfcbe52e128fc29fe1a35c793", + "symbol": "CallHistoryRequest", + "fields": { + "limit": "i64", + "offset": "i64" + } + }, + "CallHistoryResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 843, + "endLine": 846, + "sourceSha256": "6f6288bea74252c71cbfcd09e19d6acbb992997f15df39b27689151a1dc63e32", + "symbol": "CallHistoryResponse", + "fields": { + "records": "Vec", + "stats": "CallStats" + } + }, + "CallVolumeResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 849, + "endLine": 853, + "sourceSha256": "e466c53c32aee5d04caac7101649a6ad7353a6aa32e61db0967d633f672c9d83", + "symbol": "CallVolumeResponse", + "fields": { + "speaker_volume": "u8", + "microphone_volume": "u8", + "muted": "bool" + } + }, + "SetCallVolumeRequest": { + "sourceFile": "backend/src/models.rs", + "startLine": 856, + "endLine": 860, + "sourceSha256": "8161574cf2af16652244291baedca90926015fcdd9f7fb5c6ca7c10d853d563a", + "symbol": "SetCallVolumeRequest", + "fields": { + "speaker_volume": "Option", + "microphone_volume": "Option", + "muted": "Option" + } + }, + "CallSettingsResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 863, + "endLine": 872, + "sourceSha256": "a344f7efe3cbff4280b06a3fa53af71d51374443e3e6023e66201607f280a6c1", + "symbol": "CallSettingsResponse", + "fields": { + "calling_line_presentation": "String", + "calling_name_presentation": "String", + "connected_line_presentation": "String", + "connected_line_restriction": "String", + "called_line_presentation": "String", + "calling_line_restriction": "String", + "hide_caller_id": "String", + "voice_call_waiting": "String" + } + }, + "SetCallSettingRequest": { + "sourceFile": "backend/src/models.rs", + "startLine": 875, + "endLine": 878, + "sourceSha256": "90ab4d69918ab3c7f6de67f338a9c20c2ba26585f54354a890134b8e1c42d68a", + "symbol": "SetCallSettingRequest", + "fields": { + "property": "String", + "value": "String" + } + }, + "CallForwardingResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 881, + "endLine": 888, + "sourceSha256": "451d94fb326995a6ce078ac4dd06ad42215771d0914b54370b0574f4c515062f", + "symbol": "CallForwardingResponse", + "fields": { + "voice_unconditional": "String", + "voice_busy": "String", + "voice_no_reply": "String", + "voice_no_reply_timeout": "u16", + "voice_not_reachable": "String", + "forwarding_flag_on_sim": "bool" + } + }, + "SetCallForwardingRequest": { + "sourceFile": "backend/src/models.rs", + "startLine": 891, + "endLine": 895, + "sourceSha256": "62bd2c62867b2f8428480220bc1bf7a45c0512ae7ba53b150ac2623481783c39", + "symbol": "SetCallForwardingRequest", + "fields": { + "forward_type": "String", + "number": "String", + "timeout": "Option" + } + }, + "ImsStatusResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 898, + "endLine": 902, + "sourceSha256": "46ac721678801d84102ae80a36d3f852c4a331f1fd0087bc215d850f6f90151d", + "symbol": "ImsStatusResponse", + "fields": { + "registered": "bool", + "voice_capable": "bool", + "sms_capable": "bool" + } + }, + "VoicemailStatusResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 905, + "endLine": 909, + "sourceSha256": "b37d9745d23f454ec5f24dc41eb62dafc061fceab26fbf847866dbb87113ce1c", + "symbol": "VoicemailStatusResponse", + "fields": { + "waiting": "bool", + "message_count": "u8", + "mailbox_number": "String" + } + }, + "SendSmsRequest": { + "sourceFile": "backend/src/models.rs", + "startLine": 912, + "endLine": 915, + "sourceSha256": "7bb13da27c965b254df83a950901cdeb3af72e86a74d366c9645a5dd30629bd5", + "symbol": "SendSmsRequest", + "fields": { + "phone_number": "String", + "content": "String" + } + }, + "SmsListRequest": { + "sourceFile": "backend/src/models.rs", + "startLine": 918, + "endLine": 924, + "sourceSha256": "c6ae086cf90c06566dd6079155b00c082c341a5eddc9d9dc3403799a55896a33", + "symbol": "SmsListRequest", + "fields": { + "limit": "i64", + "offset": "i64", + "direction": "Option" + } + }, + "SmsConversationRequest": { + "sourceFile": "backend/src/models.rs", + "startLine": 927, + "endLine": 931, + "sourceSha256": "f13373dc53097cfc6a311162fbb412fce9799880ee71bced82a70c9c2ea64dd4", + "symbol": "SmsConversationRequest", + "fields": { + "phone_number": "String", + "limit": "i64" + } + }, + "SmsBatchDeleteRequest": { + "sourceFile": "backend/src/models.rs", + "startLine": 934, + "endLine": 939, + "sourceSha256": "3e952d11ae6ff6d62408df163ea1827ffd65bf064b9ccacbe408366350980f2f", + "symbol": "SmsBatchDeleteRequest", + "fields": { + "ids": "Vec", + "phone_numbers": "Vec" + } + }, + "SmsListResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 946, + "endLine": 948, + "sourceSha256": "207d772eb68ac5872f189b8a88b9e3c8cb74130337d7aaae2f631ae0f8a5b3fa", + "symbol": "SmsListResponse", + "fields": { + "messages": "Vec" + } + }, + "WebhookTestResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 953, + "endLine": 956, + "sourceSha256": "03fcc119e593f9b4bf2c21e40911c3da6b4fb7892c530c3885e760ae0a28d89c", + "symbol": "WebhookTestResponse", + "fields": { + "success": "bool", + "message": "String" + } + }, + "OtaMeta": { + "sourceFile": "backend/src/models.rs", + "startLine": 959, + "endLine": 968, + "sourceSha256": "1c321f611b2d22ce454a52c8213c1276d7a6836538ea42e4acc581ebce23aa95", + "symbol": "OtaMeta", + "fields": { + "version": "String", + "commit": "String", + "build_time": "String", + "binary_md5": "String", + "frontend_md5": "String", + "arch": "String", + "min_version": "Option" + } + }, + "OtaStatusResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 971, + "endLine": 977, + "sourceSha256": "b47b2e2f0fe1f26d05326dafc05158b192992df26dba541bfc18fd3ef132c723", + "symbol": "OtaStatusResponse", + "fields": { + "current_version": "String", + "current_commit": "String", + "pending_update": "bool", + "pending_meta": "Option" + } + }, + "OtaUploadResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 980, + "endLine": 983, + "sourceSha256": "17518f664e9ac5b281203ddda370fb8dd291c31fe863ec2ffad660e9f092518b", + "symbol": "OtaUploadResponse", + "fields": { + "meta": "OtaMeta", + "validation": "OtaValidation" + } + }, + "OtaOnlinePrepareRequest": { + "sourceFile": "backend/src/models.rs", + "startLine": 986, + "endLine": 988, + "sourceSha256": "e32ec913a9464b6cde62845a55cdd4842e395dae5875484394521b3c85f423d0", + "symbol": "OtaOnlinePrepareRequest", + "fields": { + "proxy_prefix": "Option" + } + }, + "OtaReleaseAsset": { + "sourceFile": "backend/src/models.rs", + "startLine": 991, + "endLine": 995, + "sourceSha256": "16e981827c41f7d835cb97d91919138c8f6e5923cd314d290c5f9271069f6440", + "symbol": "OtaReleaseAsset", + "fields": { + "name": "String", + "size": "u64", + "browser_download_url": "String" + } + }, + "OtaLatestReleaseResponse": { + "sourceFile": "backend/src/models.rs", + "startLine": 998, + "endLine": 1006, + "sourceSha256": "81fb4dc12d778b756679dd951f42947f9356d9a15f14e9178c417e7882e3e7f5", + "symbol": "OtaLatestReleaseResponse", + "fields": { + "tag_name": "String", + "name": "Option", + "published_at": "String", + "target_commitish": "Option", + "body": "Option", + "html_url": "Option", + "assets": "Vec" + } + }, + "VersionUpdateEvent": { + "sourceFile": "backend/src/models.rs", + "startLine": 1009, + "endLine": 1016, + "sourceSha256": "7d2cdc4a0bdf1d30f939ec8361d21b565af242ffec4d3b9e2535232dd12d6bcd", + "symbol": "VersionUpdateEvent", + "fields": { + "asset_name": "String", + "version": "String", + "build_time": "String", + "release_url": "String", + "timestamp": "String", + "own_number": "String" + } + }, + "OtaValidation": { + "sourceFile": "backend/src/models.rs", + "startLine": 1019, + "endLine": 1027, + "sourceSha256": "ec333e487bd928e423f75b3d1f444c77e73e72d24a83e21b458b455a46148d93", + "symbol": "OtaValidation", + "fields": { + "valid": "bool", + "is_newer": "bool", + "binary_md5_match": "bool", + "frontend_md5_match": "bool", + "arch_match": "bool", + "error": "Option" + } + }, + "OtaApplyRequest": { + "sourceFile": "backend/src/models.rs", + "startLine": 1030, + "endLine": 1033, + "sourceSha256": "adbbb9f4fe67cbdde1a4b0d2a3077b6db69e1e568b14b633ab68a0de15d24833", + "symbol": "OtaApplyRequest", + "fields": { + "restart_now": "bool" + } + }, + "UpdateSimCacheRequest": { + "sourceFile": "backend/src/models.rs", + "startLine": 1036, + "endLine": 1039, + "sourceSha256": "fb8e27a7db6f68577ab140f19ac52725dd83a211623256602dbdea643f420de9", + "symbol": "UpdateSimCacheRequest", + "fields": { + "phone_number": "Option", + "sms_center": "Option" + } + }, + "NotificationQueueQuery": { + "sourceFile": "backend/src/notification_queue.rs", + "startLine": 14, + "endLine": 17, + "sourceSha256": "82679b6e977664dd33df66758613aca8906f01b2dec32e05867bca1139ffc301", + "symbol": "NotificationQueueQuery", + "fields": { + "limit": "i64" + } + }, + "DdnsRuntimeState": { + "sourceFile": "backend/src/device_network.rs", + "startLine": 32, + "endLine": 40, + "sourceSha256": "152635e50d09951340dd81b57a64315261f31d705bffefef0400d1c7e7178dee", + "symbol": "DdnsRuntimeState", + "fields": {} + }, + "DdnsManager": { + "sourceFile": "backend/src/device_network.rs", + "startLine": 43, + "endLine": 46, + "sourceSha256": "82222e52d831f4132f0ed2db9ccef72171e496c55d20186a68d47bedc3772518", + "symbol": "DdnsManager", + "fields": {} + }, + "ProviderUpdateSummary": { + "sourceFile": "backend/src/device_network.rs", + "startLine": 381, + "endLine": 385, + "sourceSha256": "6c53e0fcba7e26421c0eec841fa2cabed4dee53080eff0dfd50329c776784bab", + "symbol": "ProviderUpdateSummary", + "fields": {} + }, + "ParsedDomain": { + "sourceFile": "backend/src/device_network.rs", + "startLine": 957, + "endLine": 960, + "sourceSha256": "abd3e2f2774891f570a9ee1e055c8470b7bfcbf66526e55f702aded1d35648b1", + "symbol": "ParsedDomain", + "fields": {} + }, + "LpacAssetCandidate": { + "sourceFile": "backend/src/esim.rs", + "startLine": 38, + "endLine": 41, + "sourceSha256": "f98d6f1299d0919667fc3654236eab0e23e04be33f172b96904b08fe3f191f4a", + "symbol": "LpacAssetCandidate", + "fields": {} + }, + "EsimApiError": { + "sourceFile": "backend/src/esim.rs", + "startLine": 44, + "endLine": 48, + "sourceSha256": "72cc378e0fd72b68e7ebe765cd8a793121e100e93d1df41a25d2069f7152f3d3", + "symbol": "EsimApiError", + "fields": {} + }, + "EsimSupervisor": { + "sourceFile": "backend/src/esim.rs", + "startLine": 59, + "endLine": 62, + "sourceSha256": "26478a4882312c09a8c2253b9c8339e7b5384502e2dced854f9af8871d8452a4", + "symbol": "EsimSupervisor", + "fields": {} + }, + "LpacProbe": { + "sourceFile": "backend/src/esim.rs", + "startLine": 319, + "endLine": 323, + "sourceSha256": "a69d1ffb8eb83a68e40b86336aaa1e2c8aad8f132c06709b84ada83de7a728d8", + "symbol": "LpacProbe", + "fields": {} + }, + "WebhookConfig": { + "sourceFile": "backend/src/config.rs", + "startLine": 17, + "endLine": 42, + "sourceSha256": "1fd95b9d09553f51c2918b65bde42829214208b472c572bca3a5b1caba19f576", + "symbol": "WebhookConfig", + "fields": { + "enabled": "bool", + "url": "String", + "forward_sms": "bool", + "forward_calls": "bool", + "forward_ddns": "bool", + "forward_updates": "bool", + "headers": "HashMap", + "start": "String", + "end": "String" + } + }, + "DeviceStatusSchedule": { + "sourceFile": "backend/src/config.rs", + "startLine": 353, + "endLine": 362, + "sourceSha256": "07308c28c23cd83ec88cb59b36cc89b280b5a5e1047d01ac1167fe846f3a17ba", + "symbol": "DeviceStatusSchedule", + "fields": { + "mode": "String", + "interval_minutes": "u32", + "weekdays": "Vec", + "times": "Vec" + } + }, + "NotificationRule": { + "sourceFile": "backend/src/config.rs", + "startLine": 437, + "endLine": 464, + "sourceSha256": "c3e609b8822d55a03c834e413b098a83251ffdf86a5cbd35accc4bd10db86558", + "symbol": "NotificationRule", + "fields": { + "id": "String", + "event_type": "NotificationEventType", + "name": "String", + "enabled": "bool", + "matcher": "RuleMatcher", + "channel_ids": "Vec", + "event_codes": "Vec", + "title_template": "String", + "template": "String", + "quiet_hours": "Vec", + "ddns_failure_threshold": "u32", + "device_status_items": "Vec", + "device_status_schedule": "DeviceStatusSchedule", + "device_status_sms_period": "String" + } + }, + "NotificationChannelInstance": { + "sourceFile": "backend/src/config.rs", + "startLine": 467, + "endLine": 478, + "sourceSha256": "63b3a8408482f9fce65602b4eba8b3062661a807110ead3794a4b62104c0ab9d", + "symbol": "NotificationChannelInstance", + "fields": { + "id": "String", + "channel_type": "NotificationChannel", + "name": "String", + "enabled": "bool", + "rate_limit": "NotificationRateLimitConfig", + "config": "Value" + } + }, + "NotificationRateLimitConfig": { + "sourceFile": "backend/src/config.rs", + "startLine": 481, + "endLine": 488, + "sourceSha256": "6a715b207813706abdd3228545a0ff448eb62402d0f77bda7b29be079e8644c9", + "symbol": "NotificationRateLimitConfig", + "fields": { + "enabled": "bool", + "max_messages": "u32", + "window_seconds": "u32" + } + }, + "NotificationLogCleanupConfig": { + "sourceFile": "backend/src/config.rs", + "startLine": 521, + "endLine": 530, + "sourceSha256": "7045da48975864ced039a9b0eb90f8e6d71150cd3964bd651f8d2ee0237eb2f4", + "symbol": "NotificationLogCleanupConfig", + "fields": { + "retention_days_enabled": "bool", + "retention_days": "u32", + "max_entries_enabled": "bool", + "max_entries": "u32" + } + }, + "NotificationConfig": { + "sourceFile": "backend/src/config.rs", + "startLine": 548, + "endLine": 557, + "sourceSha256": "7c760845eebafb3ac64f3ff4d7df29564fcab9438ebdb1a248f34cd479d70b25", + "symbol": "NotificationConfig", + "fields": { + "version": "u8", + "channels": "Vec", + "rules": "Vec", + "log_cleanup": "NotificationLogCleanupConfig" + } + }, + "NotificationConfigV2": { + "sourceFile": "backend/src/config.rs", + "startLine": 560, + "endLine": 569, + "sourceSha256": "c838e874e9264354f600fe2da7a6fec6af22987e699bd9302445ce18f92cb260", + "symbol": "NotificationConfigV2", + "fields": {} + }, + "DeviceNetworkConfig": { + "sourceFile": "backend/src/config.rs", + "startLine": 597, + "endLine": 600, + "sourceSha256": "f92246afb1e779b40a82fb32ea256edd0fb0ade11543360f5bc5e0d767d51be4", + "symbol": "DeviceNetworkConfig", + "fields": { + "ddns": "DdnsConfig" + } + }, + "VersionUpdateNotificationConfig": { + "sourceFile": "backend/src/config.rs", + "startLine": 604, + "endLine": 611, + "sourceSha256": "6afed94cd2b784530e9c4933751d67ac71118866f3d6d88068ef75001554a19c", + "symbol": "VersionUpdateNotificationConfig", + "fields": { + "enabled": "bool", + "proxy_prefix": "String", + "last_notified_version": "Option" + } + }, + "SecurityConfig": { + "sourceFile": "backend/src/config.rs", + "startLine": 615, + "endLine": 630, + "sourceSha256": "46e713f4967fd16a734acb602a92f9baa202d90ca57f506ae4591d8f6b645637", + "symbol": "SecurityConfig", + "fields": { + "password_protection_enabled": "bool", + "password_min_length": "u8", + "password_require_letters": "bool", + "password_require_digits": "bool", + "password_require_symbols": "bool", + "session_ttl_seconds": "i64", + "idle_timeout_seconds": "i64" + } + }, + "DdnsConfig": { + "sourceFile": "backend/src/config.rs", + "startLine": 634, + "endLine": 651, + "sourceSha256": "7e28db1824cb5f92be4a123cc55a80d54533ad70873b7897a61d1aba1ed77d4f", + "symbol": "DdnsConfig", + "fields": { + "enabled": "bool", + "provider": "String", + "access_id": "String", + "access_secret": "String", + "interval_seconds": "u64", + "ttl": "u32", + "ipv4": "DdnsIpConfig", + "ipv6": "DdnsIpConfig" + } + }, + "DdnsIpConfig": { + "sourceFile": "backend/src/config.rs", + "startLine": 655, + "endLine": 666, + "sourceSha256": "2a0c2c1ddb0241cbe04602742a5665132416a6c4b25fbc380233b53dafb86899", + "symbol": "DdnsIpConfig", + "fields": { + "enabled": "bool", + "get_type": "String", + "interface_name": "String", + "urls": "Vec", + "domains": "Vec" + } + }, + "LegacyChannelMigration": { + "sourceFile": "backend/src/config.rs", + "startLine": 964, + "endLine": 976, + "sourceSha256": "53058b8cb03d3156767fe34deb8cf900d10dba3685a6e4925fbf4c071c15cb3c", + "symbol": "LegacyChannelMigration", + "fields": {} + }, + "AutomationConfig": { + "sourceFile": "backend/src/config.rs", + "startLine": 1394, + "endLine": 1399, + "sourceSha256": "0442bb2cb71a2f10ff3622a2e82c95100653a25abde4082aff2bad437f7ac440", + "symbol": "AutomationConfig", + "fields": { + "enabled": "bool", + "tasks": "Vec" + } + }, + "AutomationTask": { + "sourceFile": "backend/src/config.rs", + "startLine": 1411, + "endLine": 1418, + "sourceSha256": "9133dfc2cf8ad64252b3471721f63ddf86c2912f845857686afcd35c6553a680", + "symbol": "AutomationTask", + "fields": { + "id": "String", + "name": "String", + "enabled": "bool", + "trigger": "AutomationTrigger", + "action": "AutomationAction" + } + }, + "AutomationTrigger": { + "sourceFile": "backend/src/config.rs", + "startLine": 1422, + "endLine": 1425, + "sourceSha256": "a5419951f8b6327947dce92b3742c4d75de9608100a827b76f5fb48b9b20024b", + "symbol": "AutomationTrigger", + "fields": {} + }, + "AutomationAction": { + "sourceFile": "backend/src/config.rs", + "startLine": 1429, + "endLine": 1438, + "sourceSha256": "abfa87a560a229fc53ba121da1c0b371b10670c2e967cb26f64fe5d7180766a9", + "symbol": "AutomationAction", + "fields": {} + }, + "ApnConfig": { + "sourceFile": "backend/src/config.rs", + "startLine": 1634, + "endLine": 1645, + "sourceSha256": "d33ea318ff135acd7c21dd9cbf2bf0e5da8becbd6730c91f50981f55e0fa0620", + "symbol": "ApnConfig", + "fields": { + "apn": "String", + "protocol": "String", + "username": "String", + "password": "String", + "auth_method": "String" + } + }, + "EsimConfig": { + "sourceFile": "backend/src/config.rs", + "startLine": 1660, + "endLine": 1665, + "sourceSha256": "07f6979b55ed6cecde92065cd63340436d6968ca3fca994d230ab0725a89bbe4", + "symbol": "EsimConfig", + "fields": { + "lpac_path": "String", + "custom_memory_total_kb": "Option" + } + }, + "AppConfig": { + "sourceFile": "backend/src/config.rs", + "startLine": 1678, + "endLine": 1702, + "sourceSha256": "ff075cc38761f882bdcaef8a9ee7244eeea305cc10dc98c66ac78a7541076ff5", + "symbol": "AppConfig", + "fields": { + "webhook": "WebhookConfig", + "notifications": "NotificationConfig", + "device_network": "DeviceNetworkConfig", + "version_update_notifications": "VersionUpdateNotificationConfig", + "security": "SecurityConfig", + "roaming_allowed": "bool", + "data_enabled": "bool", + "apn": "ApnConfig", + "work_mode": "WorkMode", + "esim": "EsimConfig", + "automation": "AutomationConfig" + } + }, + "ConfigManager": { + "sourceFile": "backend/src/config.rs", + "startLine": 1856, + "endLine": 1859, + "sourceSha256": "fb9c6884cdcfe3969b5d575455355d7b610b75f968ff38d3ec97a6348f36049a", + "symbol": "ConfigManager", + "fields": {} + }, + "SmsMessage": { + "sourceFile": "backend/src/db.rs", + "startLine": 16, + "endLine": 24, + "sourceSha256": "b988a9a7f0f5d77a13cb5d251b698d09da123a5f8a34f52a8997fa421fc92d52", + "symbol": "SmsMessage", + "fields": { + "id": "i64", + "direction": "String", + "phone_number": "String", + "content": "String", + "timestamp": "String", + "status": "String", + "pdu": "Option" + } + }, + "CallRecord": { + "sourceFile": "backend/src/db.rs", + "startLine": 28, + "endLine": 36, + "sourceSha256": "440f3c2077bdc17cd0ae99791101a13418014a790b5e6aa1da63a66e076dfbf5", + "symbol": "CallRecord", + "fields": { + "id": "i64", + "direction": "String", + "phone_number": "String", + "duration": "i64", + "start_time": "String", + "end_time": "Option", + "answered": "bool" + } + }, + "SmsStats": { + "sourceFile": "backend/src/db.rs", + "startLine": 40, + "endLine": 48, + "sourceSha256": "79397c5ad89be0e951871df2fee041df062fbe49107d5b73cf81ed9f68c167c1", + "symbol": "SmsStats", + "fields": { + "total": "i64", + "incoming": "i64", + "outgoing": "i64", + "pushed": "i64", + "push_attempted": "i64" + } + }, + "NotificationLogEntry": { + "sourceFile": "backend/src/db.rs", + "startLine": 51, + "endLine": 62, + "sourceSha256": "499d4ad7cfbfbcecbb06d019c87ec8b4d21ecfd1d82a7395a24053213e1a298d", + "symbol": "NotificationLogEntry", + "fields": { + "id": "i64", + "event_type": "String", + "status": "String", + "summary": "String", + "rule_id": "String", + "rule_name": "String", + "channel_id": "String", + "channel_name": "String", + "message": "String", + "created_at": "String" + } + }, + "NotificationLogsResponse": { + "sourceFile": "backend/src/db.rs", + "startLine": 65, + "endLine": 68, + "sourceSha256": "0d34948733edf17aa3ca294ea943f09902eec045c2ed705f145d553183f62ad5", + "symbol": "NotificationLogsResponse", + "fields": { + "logs": "Vec", + "total": "i64" + } + }, + "AutomationLogEntry": { + "sourceFile": "backend/src/db.rs", + "startLine": 71, + "endLine": 79, + "sourceSha256": "374d53bcb11da62030dcbd63375ef70bd411e2850e9df2de8c045e33628db0ff", + "symbol": "AutomationLogEntry", + "fields": { + "id": "i64", + "task_id": "String", + "task_name": "String", + "task_type": "String", + "status": "String", + "detail": "String", + "created_at": "String" + } + }, + "AutomationLogsResponse": { + "sourceFile": "backend/src/db.rs", + "startLine": 82, + "endLine": 85, + "sourceSha256": "eabd341921fc6f0c6c724428584bb66c78d447a6d2904aa5e630d8b760883c11", + "symbol": "AutomationLogsResponse", + "fields": { + "logs": "Vec", + "total": "i64" + } + }, + "NotificationStatusCounts": { + "sourceFile": "backend/src/db.rs", + "startLine": 88, + "endLine": 94, + "sourceSha256": "3c6d76218cecf6e29ec21688d73853b6968e035a57138ada0a97d426cff8224e", + "symbol": "NotificationStatusCounts", + "fields": { + "success": "i64", + "failed": "i64", + "quiet_hours": "i64", + "unmatched": "i64", + "no_available_channel": "i64" + } + }, + "PeriodSmsStats": { + "sourceFile": "backend/src/db.rs", + "startLine": 97, + "endLine": 100, + "sourceSha256": "5b42a04edbbc98e97839136a3ba72ca4b260cbc8e46254abdd83d13382ba228a", + "symbol": "PeriodSmsStats", + "fields": { + "incoming": "i64", + "forwarding": "NotificationStatusCounts" + } + }, + "NewNotificationLog": { + "sourceFile": "backend/src/db.rs", + "startLine": 102, + "endLine": 111, + "sourceSha256": "79efef7862c0048f865bb04cd5995687fa20389c85b7d8045e6eae63bceb4275", + "symbol": "NewNotificationLog", + "fields": { + "event_type": "&'a str", + "status": "&'a str", + "summary": "&'a str", + "rule_id": "&'a str", + "rule_name": "&'a str", + "channel_id": "&'a str", + "channel_name": "&'a str", + "message": "&'a str" + } + }, + "NewNotificationQueueItem": { + "sourceFile": "backend/src/db.rs", + "startLine": 113, + "endLine": 128, + "sourceSha256": "4ce736f56d3e34dff99a9b3dcfaa04c4882eef4a2254041f5f5624848761fa78", + "symbol": "NewNotificationQueueItem", + "fields": { + "status": "&'a str", + "event_type": "&'a str", + "event_label": "&'a str", + "summary": "&'a str", + "reason": "&'a str", + "rule_id": "&'a str", + "rule_name": "&'a str", + "channel_id": "&'a str", + "channel_name": "&'a str", + "channel_type": "&'a str", + "title": "&'a str", + "body": "&'a str", + "next_attempt_at": "&'a str", + "max_attempts": "i64" + } + }, + "NotificationQueueEntry": { + "sourceFile": "backend/src/db.rs", + "startLine": 131, + "endLine": 150, + "sourceSha256": "65da156093357103937bac162816f79ce4a3a778d1947f875e3da148cc59a5da", + "symbol": "NotificationQueueEntry", + "fields": { + "id": "i64", + "status": "String", + "event_type": "String", + "event_label": "String", + "summary": "String", + "reason": "String", + "channel_id": "String", + "channel_name": "String", + "channel_type": "String", + "rule_id": "String", + "rule_name": "String", + "title": "String", + "body": "String", + "next_attempt_at": "String", + "attempt_count": "i64", + "max_attempts": "i64", + "created_at": "String", + "updated_at": "String" + } + }, + "NotificationQueueResponse": { + "sourceFile": "backend/src/db.rs", + "startLine": 153, + "endLine": 156, + "sourceSha256": "b713fde69f183529a6404d58e5f8cab7463827fe22cb591696e7b9ac9e9045e6", + "symbol": "NotificationQueueResponse", + "fields": { + "items": "Vec", + "total": "i64" + } + }, + "CallStats": { + "sourceFile": "backend/src/db.rs", + "startLine": 160, + "endLine": 166, + "sourceSha256": "d12fd83a934605f5a81f909cb656f4862f3bbf85b3486baa901bfafdfc943f01", + "symbol": "CallStats", + "fields": { + "total": "i64", + "incoming": "i64", + "outgoing": "i64", + "missed": "i64", + "total_duration": "i64" + } + }, + "SmscCacheEntry": { + "sourceFile": "backend/src/db.rs", + "startLine": 169, + "endLine": 173, + "sourceSha256": "3678e0b4e409d3b3c15240546a1fe920dabbe90c0e33f989ca456a031bdacc0d", + "symbol": "SmscCacheEntry", + "fields": { + "sms_center": "String", + "source": "String", + "updated_at": "String" + } + }, + "OwnNumberCacheEntry": { + "sourceFile": "backend/src/db.rs", + "startLine": 176, + "endLine": 180, + "sourceSha256": "e07ff57d9819bbb237de8e02fbe84108f3d300d637435c2a5f9beb6ddceec707", + "symbol": "OwnNumberCacheEntry", + "fields": { + "phone_numbers": "Vec", + "source": "String", + "updated_at": "String" + } + }, + "SmsStorageCacheEntry": { + "sourceFile": "backend/src/db.rs", + "startLine": 183, + "endLine": 188, + "sourceSha256": "b5721d240664b15c6a359843f95886ae9914119285fb3f04008c1da377ffd6db", + "symbol": "SmsStorageCacheEntry", + "fields": { + "sms_used": "Option", + "sms_total": "Option", + "source": "String", + "updated_at": "String" + } + }, + "EsimProfileCacheEntry": { + "sourceFile": "backend/src/db.rs", + "startLine": 191, + "endLine": 208, + "sourceSha256": "22eb0b19df0ac6d4c73129f44e4f655d70741a099935496abd08a7a95c31a34b", + "symbol": "EsimProfileCacheEntry", + "fields": { + "iccid": "String", + "name": "Option", + "provider": "Option", + "state": "Option", + "profile_class": "Option", + "imsi": "Option", + "msisdn": "Option", + "smsc": "Option", + "smdp": "Option", + "matching_id": "Option", + "isdp_aid": "Option", + "mcc": "Option", + "mnc": "Option", + "disable_allowed": "Option", + "delete_allowed": "Option", + "updated_at": "String" + } + }, + "EsimEuiccCacheEntry": { + "sourceFile": "backend/src/db.rs", + "startLine": 211, + "endLine": 221, + "sourceSha256": "4ac028865ced76e8411fe6567bed51517edca59b0dd31f28ba059d6826588284", + "symbol": "EsimEuiccCacheEntry", + "fields": { + "cache_key": "String", + "eid": "String", + "status": "String", + "manufacturer": "String", + "memory_total_kb": "Option", + "memory_available_kb": "Option", + "memory_total_customizable": "Option", + "raw": "String", + "updated_at": "String" + } + }, + "Database": { + "sourceFile": "backend/src/db.rs", + "startLine": 224, + "endLine": 226, + "sourceSha256": "f02ce8ad002a3d5eefa6905ecbb8690f7d8bedb2fb2e86fe72d645e8d14e5e0d", + "symbol": "Database", + "fields": {} + } +} as const; diff --git a/packages/operation-registry/src/structured-evidence-58e2204.ts b/packages/operation-registry/src/structured-evidence-58e2204.ts new file mode 100644 index 0000000..5a1e4ee --- /dev/null +++ b/packages/operation-registry/src/structured-evidence-58e2204.ts @@ -0,0 +1,5254 @@ +/** Generated from the frozen upstream snapshot; do not hand-edit claims. */ +export const structuredEvidence58e2204 = { + "GET /api/device": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1076, + "endLine": 1090, + "sourceSha256": "7b4d3ba9a85015e504518640ae05ad6014a98aff50166308b4cd348290cca639", + "symbols": [ + { + "role": "handler", + "symbol": "get_device_info" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1076, + "endLine": 1090, + "sourceSha256": "7b4d3ba9a85015e504518640ae05ad6014a98aff50166308b4cd348290cca639", + "symbols": [ + { + "role": "handler", + "symbol": "get_device_info" + }, + { + "role": "response-payload", + "symbol": "DeviceInfoResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 223, + "endLine": 231, + "sourceSha256": "dcd68ecf845280d6c19779a2e9617c4d0ec87b1823b0c45c072139f59556c5c4", + "symbol": "DeviceInfoResponse", + "fields": [ + "imei", + "manufacturer", + "model", + "online", + "powered", + "revision" + ] + } + ] + } + }, + "GET /api/sim": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1126, + "endLine": 1145, + "sourceSha256": "db5280e4e6d37c435a5a0b727087e2ed0d2e09a14901566ff62c7a78fe5685c8", + "symbols": [ + { + "role": "handler", + "symbol": "get_sim_info" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1126, + "endLine": 1145, + "sourceSha256": "db5280e4e6d37c435a5a0b727087e2ed0d2e09a14901566ff62c7a78fe5685c8", + "symbols": [ + { + "role": "handler", + "symbol": "get_sim_info" + }, + { + "role": "response-payload", + "symbol": "SimInfoResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 292, + "endLine": 319, + "sourceSha256": "3740641a14b199ffdbdabc1e5c6dc1ef1a1e68fbbfedb81800194fdd1d1e77b1", + "symbol": "SimInfoResponse", + "fields": [ + "active", + "carrier_config", + "carrier_config_revision", + "esim_status", + "iccid", + "imsi", + "lock_status", + "mcc", + "mnc", + "modem_path", + "operator_name", + "phone_number_is_manual", + "phone_numbers", + "pin1_retries", + "pin2_retries", + "present", + "puk1_retries", + "puk2_retries", + "registered_operator_code", + "registered_operator_name", + "sim_path", + "sim_type", + "sms_center", + "sms_center_is_manual", + "sms_total", + "sms_used" + ] + } + ] + } + }, + "POST /api/sim/details/refresh": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1148, + "endLine": 1164, + "sourceSha256": "ab04a42ae36c34953d81c4ae7418ee489cf8b1b1604961dac9015b80e24677a2", + "symbols": [ + { + "role": "handler", + "symbol": "refresh_sim_details_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "dynamic-json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1148, + "endLine": 1164, + "sourceSha256": "ab04a42ae36c34953d81c4ae7418ee489cf8b1b1604961dac9015b80e24677a2", + "symbols": [ + { + "role": "handler", + "symbol": "refresh_sim_details_handler" + } + ], + "modelEvidence": [] + } + }, + "POST /api/sim/cache": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1167, + "endLine": 1213, + "sourceSha256": "b2b5f366ff8b133d4a8cbc7965a5ab50f1ee5a41b23a6c81019824d801a5798f", + "symbols": [ + { + "role": "handler", + "symbol": "update_sim_cache_handler" + }, + { + "role": "request-model", + "symbol": "UpdateSimCacheRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 1036, + "endLine": 1039, + "sourceSha256": "fb8e27a7db6f68577ab140f19ac52725dd83a211623256602dbdea643f420de9", + "symbol": "UpdateSimCacheRequest", + "fields": [ + "phone_number", + "sms_center" + ] + } + ] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1167, + "endLine": 1213, + "sourceSha256": "b2b5f366ff8b133d4a8cbc7965a5ab50f1ee5a41b23a6c81019824d801a5798f", + "symbols": [ + { + "role": "handler", + "symbol": "update_sim_cache_handler" + } + ], + "modelEvidence": [] + } + }, + "GET /api/network": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1218, + "endLine": 1232, + "sourceSha256": "5d02208a69b790246dfc8d08647fd728517d3e1777acb3cf3dd83f8e589a9e2d", + "symbols": [ + { + "role": "handler", + "symbol": "get_network_info" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1218, + "endLine": 1232, + "sourceSha256": "5d02208a69b790246dfc8d08647fd728517d3e1777acb3cf3dd83f8e589a9e2d", + "symbols": [ + { + "role": "handler", + "symbol": "get_network_info" + }, + { + "role": "response-payload", + "symbol": "NetworkInfoResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 322, + "endLine": 331, + "sourceSha256": "db4bc775f1957f9beda261e2d5f0dadb8736e48bfe9fa46c9938c51cf0622ca5", + "symbol": "NetworkInfoResponse", + "fields": [ + "mcc", + "mnc", + "operator_name", + "registration_status", + "signal_strength", + "technology_preference" + ] + } + ] + } + }, + "GET /api/cells": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1235, + "endLine": 1249, + "sourceSha256": "2a0eb9db873a461af208a2784c9a07fff1a00fcb3666fcaaa8f67840783a8dde", + "symbols": [ + { + "role": "handler", + "symbol": "get_cells" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1235, + "endLine": 1249, + "sourceSha256": "2a0eb9db873a461af208a2784c9a07fff1a00fcb3666fcaaa8f67840783a8dde", + "symbols": [ + { + "role": "handler", + "symbol": "get_cells" + }, + { + "role": "response-payload", + "symbol": "CellsResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 216, + "endLine": 220, + "sourceSha256": "0d100dcc32419e35c6307d682392aee9da6cfb709f7c0a41fd444f5fea806a4a", + "symbol": "CellsResponse", + "fields": [ + "cells", + "serving_cell" + ] + } + ] + } + }, + "POST /api/cell-monitor/start": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1252, + "endLine": 1282, + "sourceSha256": "c70d5916a800de22a643c93e2a9ddb7a6a4eeb9e8ad83fee1fbd3ee7660b78fe", + "symbols": [ + { + "role": "handler", + "symbol": "start_cell_monitor_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1252, + "endLine": 1282, + "sourceSha256": "c70d5916a800de22a643c93e2a9ddb7a6a4eeb9e8ad83fee1fbd3ee7660b78fe", + "symbols": [ + { + "role": "handler", + "symbol": "start_cell_monitor_handler" + } + ], + "modelEvidence": [] + } + }, + "POST /api/cell-monitor/stop": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1285, + "endLine": 1312, + "sourceSha256": "81303fb59efb78b8ab8901a3a1f693305ff13fc3048d51138c6a9e054ee73c66", + "symbols": [ + { + "role": "handler", + "symbol": "stop_cell_monitor_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1285, + "endLine": 1312, + "sourceSha256": "81303fb59efb78b8ab8901a3a1f693305ff13fc3048d51138c6a9e054ee73c66", + "symbols": [ + { + "role": "handler", + "symbol": "stop_cell_monitor_handler" + } + ], + "modelEvidence": [] + } + }, + "GET /api/radio-mode": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1315, + "endLine": 1329, + "sourceSha256": "caba610280c2850fd8a840114af3c9b3b69c4ba0f9a724a23cdb92c78aa222ea", + "symbols": [ + { + "role": "handler", + "symbol": "get_radio_mode_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1315, + "endLine": 1329, + "sourceSha256": "caba610280c2850fd8a840114af3c9b3b69c4ba0f9a724a23cdb92c78aa222ea", + "symbols": [ + { + "role": "handler", + "symbol": "get_radio_mode_handler" + }, + { + "role": "response-payload", + "symbol": "RadioModeResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 344, + "endLine": 348, + "sourceSha256": "ee2c006cc5e1a03e0bc8d1b720a80ba9dd0b0a4e65725349af49bf9fbc26dddf", + "symbol": "RadioModeResponse", + "fields": [ + "mode", + "supported_modes", + "technology_preference" + ] + } + ] + } + }, + "POST /api/radio-mode": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1332, + "endLine": 1352, + "sourceSha256": "c41c6134fe1f28b0f018724a009a4df537556f1226be4e27924d7daa10ce3de2", + "symbols": [ + { + "role": "handler", + "symbol": "set_radio_mode_handler" + }, + { + "role": "request-model", + "symbol": "RadioModeRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 351, + "endLine": 353, + "sourceSha256": "826ab90aeb4cf5c0c89d98b6cc75f922938ba42d10a264c913532bc50420419e", + "symbol": "RadioModeRequest", + "fields": [ + "mode" + ] + } + ] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1332, + "endLine": 1352, + "sourceSha256": "c41c6134fe1f28b0f018724a009a4df537556f1226be4e27924d7daa10ce3de2", + "symbols": [ + { + "role": "handler", + "symbol": "set_radio_mode_handler" + } + ], + "modelEvidence": [] + } + }, + "GET /api/band-lock": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1355, + "endLine": 1369, + "sourceSha256": "6130c68b619423b3303d2fcf24fa6175c979c80c6bb2158460c2455d7b347f72", + "symbols": [ + { + "role": "handler", + "symbol": "get_band_lock_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1355, + "endLine": 1369, + "sourceSha256": "6130c68b619423b3303d2fcf24fa6175c979c80c6bb2158460c2455d7b347f72", + "symbols": [ + { + "role": "handler", + "symbol": "get_band_lock_handler" + }, + { + "role": "response-payload", + "symbol": "BandLockStatus" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 356, + "endLine": 374, + "sourceSha256": "ee0a2e9cd4fd25858a753b9e28566d78e5cf85b4135619ee7074346ce54e9cb9", + "symbol": "BandLockStatus", + "fields": [ + "locked", + "lte_fdd_bands", + "lte_tdd_bands", + "nr_fdd_bands", + "nr_tdd_bands", + "supported_lte_fdd_bands", + "supported_lte_tdd_bands", + "supported_nr_fdd_bands", + "supported_nr_tdd_bands" + ] + } + ] + } + }, + "POST /api/band-lock": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1372, + "endLine": 1392, + "sourceSha256": "04504f8b9b2d528e6e367bea397ef722f4f1eb3aa7363946718ea9871ac20277", + "symbols": [ + { + "role": "handler", + "symbol": "set_band_lock_handler" + }, + { + "role": "request-model", + "symbol": "BandLockRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 377, + "endLine": 386, + "sourceSha256": "690b44d489734f4db1daa819e25099b2e1fbc1982c45d2a08a25bd6ab569235c", + "symbol": "BandLockRequest", + "fields": [ + "lte_fdd_bands", + "lte_tdd_bands", + "nr_fdd_bands", + "nr_tdd_bands" + ] + } + ] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1372, + "endLine": 1392, + "sourceSha256": "04504f8b9b2d528e6e367bea397ef722f4f1eb3aa7363946718ea9871ac20277", + "symbols": [ + { + "role": "handler", + "symbol": "set_band_lock_handler" + } + ], + "modelEvidence": [] + } + }, + "GET /api/network/interfaces": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1619, + "endLine": 1644, + "sourceSha256": "a1ac88c365ed70bfccbe2efdc5a2788d3d4bb74115c330fdc319c2eebc074521", + "symbols": [ + { + "role": "handler", + "symbol": "get_network_interfaces_info" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1619, + "endLine": 1644, + "sourceSha256": "a1ac88c365ed70bfccbe2efdc5a2788d3d4bb74115c330fdc319c2eebc074521", + "symbols": [ + { + "role": "handler", + "symbol": "get_network_interfaces_info" + }, + { + "role": "response-payload", + "symbol": "NetworkInterfacesResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 705, + "endLine": 708, + "sourceSha256": "b9788a7f2f5135cfe2114c280172286d65d9f8c44dcb78bb8ccf4728341d0dc1", + "symbol": "NetworkInterfacesResponse", + "fields": [ + "interfaces", + "total_count" + ] + } + ] + } + }, + "GET /api/network/connection-addresses": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1647, + "endLine": 1666, + "sourceSha256": "a5f28ff95121c09750c5fc3f81192a0d15c0a794e16954d2a2783f357319b82f", + "symbols": [ + { + "role": "handler", + "symbol": "get_network_connection_addresses" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1647, + "endLine": 1666, + "sourceSha256": "a5f28ff95121c09750c5fc3f81192a0d15c0a794e16954d2a2783f357319b82f", + "symbols": [ + { + "role": "handler", + "symbol": "get_network_connection_addresses" + }, + { + "role": "response-payload", + "symbol": "ConnectionAddressesResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 711, + "endLine": 718, + "sourceSha256": "79a8ad8d4e3eea5ea329c5e7cefa2e9518953b0645bae367d04c2fb596118c57", + "symbol": "ConnectionAddressesResponse", + "fields": [ + "ipv4", + "ipv4_interface", + "ipv6", + "ipv6_interface" + ] + } + ] + } + }, + "GET /api/device-network/ddns/config": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1669, + "endLine": 1679, + "sourceSha256": "4ec8e855d61695c9db55ba9afb333005f6bbb4e0bdae5e22988173431d139b1b", + "symbols": [ + { + "role": "handler", + "symbol": "get_device_ddns_config_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "dynamic-json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1669, + "endLine": 1679, + "sourceSha256": "4ec8e855d61695c9db55ba9afb333005f6bbb4e0bdae5e22988173431d139b1b", + "symbols": [ + { + "role": "handler", + "symbol": "get_device_ddns_config_handler" + } + ], + "modelEvidence": [] + } + }, + "POST /api/device-network/ddns/config": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1682, + "endLine": 1721, + "sourceSha256": "10137722721a3ef9bea62ce99f7225143652521a634c928daa7c389978e69d00", + "symbols": [ + { + "role": "handler", + "symbol": "set_device_ddns_config_handler" + }, + { + "role": "request-model", + "symbol": "DdnsConfig" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/config.rs", + "startLine": 634, + "endLine": 651, + "sourceSha256": "7e28db1824cb5f92be4a123cc55a80d54533ad70873b7897a61d1aba1ed77d4f", + "symbol": "DdnsConfig", + "fields": [ + "access_id", + "access_secret", + "enabled", + "interval_seconds", + "ipv4", + "ipv6", + "provider", + "ttl" + ] + } + ] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1682, + "endLine": 1721, + "sourceSha256": "10137722721a3ef9bea62ce99f7225143652521a634c928daa7c389978e69d00", + "symbols": [ + { + "role": "handler", + "symbol": "set_device_ddns_config_handler" + } + ], + "modelEvidence": [] + } + }, + "GET /api/device-network/ddns/status": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1750, + "endLine": 1757, + "sourceSha256": "02494bf7928a9a23ec81cab75333d7322683628a1c33ea067d7e4ad52177f8f6", + "symbols": [ + { + "role": "handler", + "symbol": "get_device_ddns_status_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "dynamic-json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1750, + "endLine": 1757, + "sourceSha256": "02494bf7928a9a23ec81cab75333d7322683628a1c33ea067d7e4ad52177f8f6", + "symbols": [ + { + "role": "handler", + "symbol": "get_device_ddns_status_handler" + } + ], + "modelEvidence": [] + } + }, + "POST /api/device-network/ddns/sync": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1760, + "endLine": 1781, + "sourceSha256": "43cf9573e2b43aaf431562572de72d81f57bb586d78c69415441859ad0ab0528", + "symbols": [ + { + "role": "handler", + "symbol": "sync_device_ddns_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1760, + "endLine": 1781, + "sourceSha256": "43cf9573e2b43aaf431562572de72d81f57bb586d78c69415441859ad0ab0528", + "symbols": [ + { + "role": "handler", + "symbol": "sync_device_ddns_handler" + }, + { + "role": "response-payload", + "symbol": "DdnsSyncResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 542, + "endLine": 546, + "sourceSha256": "680749e91404c508babd10a2387399322dbc2a0e87c1ef823ead3cf93f11dae4", + "symbol": "DdnsSyncResponse", + "fields": [ + "finished_at", + "records", + "started_at" + ] + } + ] + } + }, + "GET /api/device-network/ddns/logs": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1784, + "endLine": 1790, + "sourceSha256": "ac80f3ada641ca13354b1b857a8eabfa67c15e30f706209bdce9331a7a903d73", + "symbols": [ + { + "role": "handler", + "symbol": "get_device_ddns_logs_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "dynamic-json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1784, + "endLine": 1790, + "sourceSha256": "ac80f3ada641ca13354b1b857a8eabfa67c15e30f706209bdce9331a7a903d73", + "symbols": [ + { + "role": "handler", + "symbol": "get_device_ddns_logs_handler" + } + ], + "modelEvidence": [] + } + }, + "POST /api/device-network/ddns/logs/clear": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1793, + "endLine": 1802, + "sourceSha256": "8ebb7d7cfe772a4e7fdee06da1ba69de668a68f0a0cb6077949e1f1ef7594173", + "symbols": [ + { + "role": "handler", + "symbol": "clear_device_ddns_logs_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "dynamic-json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1793, + "endLine": 1802, + "sourceSha256": "8ebb7d7cfe772a4e7fdee06da1ba69de668a68f0a0cb6077949e1f1ef7594173", + "symbols": [ + { + "role": "handler", + "symbol": "clear_device_ddns_logs_handler" + } + ], + "modelEvidence": [] + } + }, + "GET /api/device-network/wlan/status": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1805, + "endLine": 1819, + "sourceSha256": "66741c07471de1f7a414b9771d6ce841fdcaf04d48244ac8cd2e03ecb1612034", + "symbols": [ + { + "role": "handler", + "symbol": "get_device_wlan_status_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1805, + "endLine": 1819, + "sourceSha256": "66741c07471de1f7a414b9771d6ce841fdcaf04d48244ac8cd2e03ecb1612034", + "symbols": [ + { + "role": "handler", + "symbol": "get_device_wlan_status_handler" + }, + { + "role": "response-payload", + "symbol": "WlanStatusResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 562, + "endLine": 573, + "sourceSha256": "a4c12c012b2970de39959bf796925d07af17fca6042c8eeef59ef7aa24e2c242", + "symbol": "WlanStatusResponse", + "fields": [ + "available", + "connected", + "connection_id", + "enabled", + "hardware_enabled", + "interface_name", + "ipv4_addresses", + "ipv4_gateway", + "ipv6_addresses", + "ssid" + ] + } + ] + } + }, + "POST /api/device-network/wlan/enabled": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1822, + "endLine": 1841, + "sourceSha256": "b6cc1238ca609208e8c2a6f757736617d2ca200e1b8a1725eda59694592582b7", + "symbols": [ + { + "role": "handler", + "symbol": "set_device_wlan_enabled_handler" + }, + { + "role": "request-model", + "symbol": "WlanEnabledRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 606, + "endLine": 608, + "sourceSha256": "5b68debfd6293c67d0856dfb87552014e0cbaf0f24d88f2e99b48743602f03b1", + "symbol": "WlanEnabledRequest", + "fields": [ + "enabled" + ] + } + ] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1822, + "endLine": 1841, + "sourceSha256": "b6cc1238ca609208e8c2a6f757736617d2ca200e1b8a1725eda59694592582b7", + "symbols": [ + { + "role": "handler", + "symbol": "set_device_wlan_enabled_handler" + }, + { + "role": "response-payload", + "symbol": "WlanStatusResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 562, + "endLine": 573, + "sourceSha256": "a4c12c012b2970de39959bf796925d07af17fca6042c8eeef59ef7aa24e2c242", + "symbol": "WlanStatusResponse", + "fields": [ + "available", + "connected", + "connection_id", + "enabled", + "hardware_enabled", + "interface_name", + "ipv4_addresses", + "ipv4_gateway", + "ipv6_addresses", + "ssid" + ] + } + ] + } + }, + "POST /api/device-network/wlan/scan": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1844, + "endLine": 1858, + "sourceSha256": "de90691d7c57a4e9af311144f3573e0ce80b3cc7ee29cc65b71c94c28e7b2651", + "symbols": [ + { + "role": "handler", + "symbol": "scan_device_wlan_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1844, + "endLine": 1858, + "sourceSha256": "de90691d7c57a4e9af311144f3573e0ce80b3cc7ee29cc65b71c94c28e7b2651", + "symbols": [ + { + "role": "handler", + "symbol": "scan_device_wlan_handler" + }, + { + "role": "response-payload", + "symbol": "WlanScanResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 586, + "endLine": 588, + "sourceSha256": "18425a0d1c1c473c1628f45fab8617974434b6245ebf2f42752cc0a1f6400c38", + "symbol": "WlanScanResponse", + "fields": [ + "networks" + ] + } + ] + } + }, + "GET /api/device-network/wlan/profiles": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1861, + "endLine": 1875, + "sourceSha256": "caadf1fc1c436c6342722c2955f0588581d26d12d1fc3db107e47e18adac43f5", + "symbols": [ + { + "role": "handler", + "symbol": "get_device_wlan_profiles_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1861, + "endLine": 1875, + "sourceSha256": "caadf1fc1c436c6342722c2955f0588581d26d12d1fc3db107e47e18adac43f5", + "symbols": [ + { + "role": "handler", + "symbol": "get_device_wlan_profiles_handler" + }, + { + "role": "response-payload", + "symbol": "WlanProfilesResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 601, + "endLine": 603, + "sourceSha256": "9da2806f9e2d7c02c0c82a269d45922abc19c6a355c51bd6fbdeec27f6ee6561", + "symbol": "WlanProfilesResponse", + "fields": [ + "profiles" + ] + } + ] + } + }, + "POST /api/device-network/wlan/forget": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1878, + "endLine": 1897, + "sourceSha256": "f5142f819f6da44230d61c9f5db90c8cb473de954faee08245a2f159d43c8a7e", + "symbols": [ + { + "role": "handler", + "symbol": "forget_device_wlan_handler" + }, + { + "role": "request-model", + "symbol": "WlanForgetRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 635, + "endLine": 640, + "sourceSha256": "8cf9de418699e5d2ef5646157b130c1e661ee4a99e0bf55c5aeea296e7b8284c", + "symbol": "WlanForgetRequest", + "fields": [ + "connection_id", + "uuid" + ] + } + ] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1878, + "endLine": 1897, + "sourceSha256": "f5142f819f6da44230d61c9f5db90c8cb473de954faee08245a2f159d43c8a7e", + "symbols": [ + { + "role": "handler", + "symbol": "forget_device_wlan_handler" + }, + { + "role": "response-payload", + "symbol": "WlanProfilesResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 601, + "endLine": 603, + "sourceSha256": "9da2806f9e2d7c02c0c82a269d45922abc19c6a355c51bd6fbdeec27f6ee6561", + "symbol": "WlanProfilesResponse", + "fields": [ + "profiles" + ] + } + ] + } + }, + "POST /api/device-network/wlan/connect": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1900, + "endLine": 1955, + "sourceSha256": "9c422666a3db49cdf0efd42873ca681ee158b4fb9183b6211c4f9522f0fe23b5", + "symbols": [ + { + "role": "handler", + "symbol": "connect_device_wlan_handler" + }, + { + "role": "request-model", + "symbol": "WlanConnectRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 611, + "endLine": 617, + "sourceSha256": "28653f519f8f46a5d48fc60c1a90b51a13da9bb216b506131a5b67ce669e136e", + "symbol": "WlanConnectRequest", + "fields": [ + "auto_join", + "password", + "ssid" + ] + } + ] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1900, + "endLine": 1955, + "sourceSha256": "9c422666a3db49cdf0efd42873ca681ee158b4fb9183b6211c4f9522f0fe23b5", + "symbols": [ + { + "role": "handler", + "symbol": "connect_device_wlan_handler" + }, + { + "role": "response-payload", + "symbol": "WlanStatusResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 562, + "endLine": 573, + "sourceSha256": "a4c12c012b2970de39959bf796925d07af17fca6042c8eeef59ef7aa24e2c242", + "symbol": "WlanStatusResponse", + "fields": [ + "available", + "connected", + "connection_id", + "enabled", + "hardware_enabled", + "interface_name", + "ipv4_addresses", + "ipv4_gateway", + "ipv6_addresses", + "ssid" + ] + } + ] + } + }, + "POST /api/device-network/wlan/disconnect": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1958, + "endLine": 1991, + "sourceSha256": "719a709dc8509a35a0e961f55359aa77d07b9e95908d8622e8b369a54ed62505", + "symbols": [ + { + "role": "handler", + "symbol": "disconnect_device_wlan_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1958, + "endLine": 1991, + "sourceSha256": "719a709dc8509a35a0e961f55359aa77d07b9e95908d8622e8b369a54ed62505", + "symbols": [ + { + "role": "handler", + "symbol": "disconnect_device_wlan_handler" + }, + { + "role": "response-payload", + "symbol": "WlanStatusResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 562, + "endLine": 573, + "sourceSha256": "a4c12c012b2970de39959bf796925d07af17fca6042c8eeef59ef7aa24e2c242", + "symbol": "WlanStatusResponse", + "fields": [ + "available", + "connected", + "connection_id", + "enabled", + "hardware_enabled", + "interface_name", + "ipv4_addresses", + "ipv4_gateway", + "ipv6_addresses", + "ssid" + ] + } + ] + } + }, + "POST /api/device-network/wlan/profile": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1994, + "endLine": 2013, + "sourceSha256": "b3180f55af718c0404c190cc47c90be10727269ab9ddc7fee9239d24a6088bcd", + "symbols": [ + { + "role": "handler", + "symbol": "save_device_wlan_profile_handler" + }, + { + "role": "request-model", + "symbol": "WlanProfileRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 620, + "endLine": 632, + "sourceSha256": "4b3c4daf6c86f0ed1dc03fd00bd233de221d53b318bb7c1e1ac04024fff88c8f", + "symbol": "WlanProfileRequest", + "fields": [ + "auto_join", + "connection_id", + "ipv4_address", + "ipv4_gateway", + "ipv4_mode", + "ipv4_prefix" + ] + } + ] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1994, + "endLine": 2013, + "sourceSha256": "b3180f55af718c0404c190cc47c90be10727269ab9ddc7fee9239d24a6088bcd", + "symbols": [ + { + "role": "handler", + "symbol": "save_device_wlan_profile_handler" + }, + { + "role": "response-payload", + "symbol": "WlanStatusResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 562, + "endLine": 573, + "sourceSha256": "a4c12c012b2970de39959bf796925d07af17fca6042c8eeef59ef7aa24e2c242", + "symbol": "WlanStatusResponse", + "fields": [ + "available", + "connected", + "connection_id", + "enabled", + "hardware_enabled", + "interface_name", + "ipv4_addresses", + "ipv4_gateway", + "ipv6_addresses", + "ssid" + ] + } + ] + } + }, + "GET /api/network/signal-strength": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2016, + "endLine": 2030, + "sourceSha256": "2c04c31da8b4160b8941a524b3016de75c75ae689a28d50f50a664fa5c4af828", + "symbols": [ + { + "role": "handler", + "symbol": "get_signal_strength_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2016, + "endLine": 2030, + "sourceSha256": "2c04c31da8b4160b8941a524b3016de75c75ae689a28d50f50a664fa5c4af828", + "symbols": [ + { + "role": "handler", + "symbol": "get_signal_strength_handler" + }, + { + "role": "response-payload", + "symbol": "SignalStrengthResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 721, + "endLine": 723, + "sourceSha256": "5bbc4951dfb85cf410e11e90929846fa63974f2d083846f1a7a4b45a1078d038", + "symbol": "SignalStrengthResponse", + "fields": [ + "strength" + ] + } + ] + } + }, + "GET /api/location/cell-info": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1395, + "endLine": 1409, + "sourceSha256": "fc748f05e03bf6843eed36f7a4dd6f785811d05e9dd8942fe2d92a4b63c62398", + "symbols": [ + { + "role": "handler", + "symbol": "get_cell_location_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1395, + "endLine": 1409, + "sourceSha256": "fc748f05e03bf6843eed36f7a4dd6f785811d05e9dd8942fe2d92a4b63c62398", + "symbols": [ + { + "role": "handler", + "symbol": "get_cell_location_handler" + }, + { + "role": "response-payload", + "symbol": "CellLocationResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 744, + "endLine": 752, + "sourceSha256": "3eb9561a78d76b8276ea681f54e3ab24c51df1c7299cfb287e5c31c33032b49c", + "symbol": "CellLocationResponse", + "fields": [ + "available", + "cell_info", + "cells", + "neighbor_cells" + ] + } + ] + } + }, + "GET /api/network/operators": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1412, + "endLine": 1426, + "sourceSha256": "d3b30db8a501b92aba2d3038e3642eb020412a2ac11e8286dc1f4860ef3c732b", + "symbols": [ + { + "role": "handler", + "symbol": "get_network_operators" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1412, + "endLine": 1426, + "sourceSha256": "d3b30db8a501b92aba2d3038e3642eb020412a2ac11e8286dc1f4860ef3c732b", + "symbols": [ + { + "role": "handler", + "symbol": "get_network_operators" + }, + { + "role": "response-payload", + "symbol": "OperatorListResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 766, + "endLine": 768, + "sourceSha256": "927df0e09ca10d0744fe1c8a65e9e0c5a8d257736898625c08d299eeb21248af", + "symbol": "OperatorListResponse", + "fields": [ + "operators" + ] + } + ] + } + }, + "GET /api/network/operators/scan": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1429, + "endLine": 1443, + "sourceSha256": "4a8bfbd38a943df8dfa9ce69f3bb162874baf7f6f69507d35257c80aff5316e6", + "symbols": [ + { + "role": "handler", + "symbol": "scan_network_operators" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1429, + "endLine": 1443, + "sourceSha256": "4a8bfbd38a943df8dfa9ce69f3bb162874baf7f6f69507d35257c80aff5316e6", + "symbols": [ + { + "role": "handler", + "symbol": "scan_network_operators" + }, + { + "role": "response-payload", + "symbol": "OperatorListResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 766, + "endLine": 768, + "sourceSha256": "927df0e09ca10d0744fe1c8a65e9e0c5a8d257736898625c08d299eeb21248af", + "symbol": "OperatorListResponse", + "fields": [ + "operators" + ] + } + ] + } + }, + "POST /api/network/register-manual": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1446, + "endLine": 1466, + "sourceSha256": "8b78cc782499ff836911c3bb5563c85ae53cc4bb05b418a01ff94f4e16916b4c", + "symbols": [ + { + "role": "handler", + "symbol": "register_network_manual" + }, + { + "role": "request-model", + "symbol": "ManualRegisterRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 771, + "endLine": 773, + "sourceSha256": "10db81d311c2a9f28710d5f5ef40204ec287cbca1e47e7c211e8b7b064e844b8", + "symbol": "ManualRegisterRequest", + "fields": [ + "mccmnc" + ] + } + ] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1446, + "endLine": 1466, + "sourceSha256": "8b78cc782499ff836911c3bb5563c85ae53cc4bb05b418a01ff94f4e16916b4c", + "symbols": [ + { + "role": "handler", + "symbol": "register_network_manual" + } + ], + "modelEvidence": [] + } + }, + "POST /api/network/register-auto": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1469, + "endLine": 1486, + "sourceSha256": "431aabcc9852603b66f9a3234afb2e76f6e6cc3845ffb4827874aef16f3744d0", + "symbols": [ + { + "role": "handler", + "symbol": "register_network_auto" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1469, + "endLine": 1486, + "sourceSha256": "431aabcc9852603b66f9a3234afb2e76f6e6cc3845ffb4827874aef16f3744d0", + "symbols": [ + { + "role": "handler", + "symbol": "register_network_auto" + } + ], + "modelEvidence": [] + } + }, + "GET /api/apn": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1489, + "endLine": 1504, + "sourceSha256": "798953ca7e3857d0ea96f60b7882dafd3893dd751f8af07afea7707dc1145631", + "symbols": [ + { + "role": "handler", + "symbol": "get_apn_list_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1489, + "endLine": 1504, + "sourceSha256": "798953ca7e3857d0ea96f60b7882dafd3893dd751f8af07afea7707dc1145631", + "symbols": [ + { + "role": "handler", + "symbol": "get_apn_list_handler" + }, + { + "role": "response-payload", + "symbol": "ApnListResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 790, + "endLine": 792, + "sourceSha256": "3e67a492b7a83e2471c28d68d8e194f69802301e4efc237a23878f34fce92a57", + "symbol": "ApnListResponse", + "fields": [ + "contexts" + ] + } + ] + } + }, + "POST /api/apn": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1507, + "endLine": 1571, + "sourceSha256": "6edced468c5f3bcb5181736b235805550d50c76ebf770b7632c6a7a38e65a8d0", + "symbols": [ + { + "role": "handler", + "symbol": "set_apn_handler" + }, + { + "role": "request-model", + "symbol": "SetApnRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 795, + "endLine": 802, + "sourceSha256": "b5732eca064eddf8a084ba3661004ce583150710217a7f2097926456922a0295", + "symbol": "SetApnRequest", + "fields": [ + "apn", + "auth_method", + "context_path", + "password", + "protocol", + "username" + ] + } + ] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1507, + "endLine": 1571, + "sourceSha256": "6edced468c5f3bcb5181736b235805550d50c76ebf770b7632c6a7a38e65a8d0", + "symbols": [ + { + "role": "handler", + "symbol": "set_apn_handler" + } + ], + "modelEvidence": [] + } + }, + "GET /api/cell-lock": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1574, + "endLine": 1582, + "sourceSha256": "fe4b4aaedc6e5ccf4d36d0a347b46999ae99290a41efaf0bedf7e37be992591e", + "symbols": [ + { + "role": "handler", + "symbol": "get_cell_lock_status_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "dynamic-json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1574, + "endLine": 1582, + "sourceSha256": "fe4b4aaedc6e5ccf4d36d0a347b46999ae99290a41efaf0bedf7e37be992591e", + "symbols": [ + { + "role": "handler", + "symbol": "get_cell_lock_status_handler" + } + ], + "modelEvidence": [] + } + }, + "POST /api/cell-lock": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1585, + "endLine": 1603, + "sourceSha256": "29ff2739b7d0b58489c537db7c2fc000dabc4ba4251e27ba34e546cf9f9735ce", + "symbols": [ + { + "role": "handler", + "symbol": "set_cell_lock_handler" + }, + { + "role": "request-model", + "symbol": "CellLockRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 405, + "endLine": 415, + "sourceSha256": "ce2683159c40e8c37e15185daca3308065d4d4be372393bfb7ea14cedec39520", + "symbol": "CellLockRequest", + "fields": [ + "arfcn", + "enable", + "lock_type", + "pci", + "rat" + ] + } + ] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1585, + "endLine": 1603, + "sourceSha256": "29ff2739b7d0b58489c537db7c2fc000dabc4ba4251e27ba34e546cf9f9735ce", + "symbols": [ + { + "role": "handler", + "symbol": "set_cell_lock_handler" + }, + { + "role": "response-payload", + "symbol": "CellLockResult" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 805, + "endLine": 807, + "sourceSha256": "8c645d5c9c9cc61d42654ad34d56885f7bb3f9718ad40995107df7dd34b8445b", + "symbol": "CellLockResult", + "fields": [ + "success" + ] + } + ] + } + }, + "POST /api/cell-lock/unlock-all": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1606, + "endLine": 1616, + "sourceSha256": "390162e5ce89c0952897c9a14054e33d502f52ad69a61b6becfe7c47f3c8be5d", + "symbols": [ + { + "role": "handler", + "symbol": "unlock_all_cells_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "dynamic-json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 1606, + "endLine": 1616, + "sourceSha256": "390162e5ce89c0952897c9a14054e33d502f52ad69a61b6becfe7c47f3c8be5d", + "symbols": [ + { + "role": "handler", + "symbol": "unlock_all_cells_handler" + } + ], + "modelEvidence": [] + } + }, + "GET /api/data": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2035, + "endLine": 2062, + "sourceSha256": "43f00a519bd67c3a500c709747a29286ccf37bdd96abd83541dcd21c3c476420", + "symbols": [ + { + "role": "handler", + "symbol": "get_data_status" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2035, + "endLine": 2062, + "sourceSha256": "43f00a519bd67c3a500c709747a29286ccf37bdd96abd83541dcd21c3c476420", + "symbols": [ + { + "role": "handler", + "symbol": "get_data_status" + }, + { + "role": "response-payload", + "symbol": "DataConnectionResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 239, + "endLine": 241, + "sourceSha256": "a2090a109ebd433d392959b49f7af25292962db4a7c6e3e9aaa3ec232cfff5f3", + "symbol": "DataConnectionResponse", + "fields": [ + "active" + ] + } + ] + } + }, + "POST /api/data": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2065, + "endLine": 2131, + "sourceSha256": "f1653cfe4442172ca80c97d1223ee4bdc6ed739d2f16e620a26285ab58b395b9", + "symbols": [ + { + "role": "handler", + "symbol": "set_data_status" + }, + { + "role": "request-model", + "symbol": "DataConnectionRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 234, + "endLine": 236, + "sourceSha256": "5809e9d26c512e308d32f835756449f923f44198f130fe1f805a982539fba68a", + "symbol": "DataConnectionRequest", + "fields": [ + "active" + ] + } + ] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2065, + "endLine": 2131, + "sourceSha256": "f1653cfe4442172ca80c97d1223ee4bdc6ed739d2f16e620a26285ab58b395b9", + "symbols": [ + { + "role": "handler", + "symbol": "set_data_status" + }, + { + "role": "response-payload", + "symbol": "DataConnectionResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 239, + "endLine": 241, + "sourceSha256": "a2090a109ebd433d392959b49f7af25292962db4a7c6e3e9aaa3ec232cfff5f3", + "symbol": "DataConnectionResponse", + "fields": [ + "active" + ] + } + ] + } + }, + "GET /api/roaming": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2172, + "endLine": 2193, + "sourceSha256": "687ff0cdb729645af41a7b5d6828f1c348da3948163376adca3418e68a7937ae", + "symbols": [ + { + "role": "handler", + "symbol": "get_roaming_status_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2172, + "endLine": 2193, + "sourceSha256": "687ff0cdb729645af41a7b5d6828f1c348da3948163376adca3418e68a7937ae", + "symbols": [ + { + "role": "handler", + "symbol": "get_roaming_status_handler" + }, + { + "role": "response-payload", + "symbol": "RoamingResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 249, + "endLine": 252, + "sourceSha256": "e40afb67d33b552ca2e1031ff3577e6144bee66d845fc957875f3ef9cd601140", + "symbol": "RoamingResponse", + "fields": [ + "is_roaming", + "roaming_allowed" + ] + } + ] + } + }, + "POST /api/roaming": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2196, + "endLine": 2247, + "sourceSha256": "b18b5c87eb24ccc952ff28bbee43f484c4e20c918ddd4f18d043a471ed3309c7", + "symbols": [ + { + "role": "handler", + "symbol": "set_roaming_status_handler" + }, + { + "role": "request-model", + "symbol": "RoamingRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 244, + "endLine": 246, + "sourceSha256": "f3bc73ca411e378ba0ca12fa9bcfc44ca562af86f83d51f56121f30081814703", + "symbol": "RoamingRequest", + "fields": [ + "allowed" + ] + } + ] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2196, + "endLine": 2247, + "sourceSha256": "b18b5c87eb24ccc952ff28bbee43f484c4e20c918ddd4f18d043a471ed3309c7", + "symbols": [ + { + "role": "handler", + "symbol": "set_roaming_status_handler" + }, + { + "role": "response-payload", + "symbol": "RoamingResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 249, + "endLine": 252, + "sourceSha256": "e40afb67d33b552ca2e1031ff3577e6144bee66d845fc957875f3ef9cd601140", + "symbol": "RoamingResponse", + "fields": [ + "is_roaming", + "roaming_allowed" + ] + } + ] + } + }, + "GET /api/airplane-mode": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2315, + "endLine": 2329, + "sourceSha256": "243c2a63fd99d45cf4729005221137e0b024f72256dbe3ea3ef83bb75bdde620", + "symbols": [ + { + "role": "handler", + "symbol": "get_airplane_mode_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2315, + "endLine": 2329, + "sourceSha256": "243c2a63fd99d45cf4729005221137e0b024f72256dbe3ea3ef83bb75bdde620", + "symbols": [ + { + "role": "handler", + "symbol": "get_airplane_mode_handler" + }, + { + "role": "response-payload", + "symbol": "AirplaneModeResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 260, + "endLine": 264, + "sourceSha256": "d93cec1a6fe1fc38a8fc2257580f16d2624e2d2ee68971116d6d5065a5feae2a", + "symbol": "AirplaneModeResponse", + "fields": [ + "enabled", + "online", + "powered" + ] + } + ] + } + }, + "POST /api/airplane-mode": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2250, + "endLine": 2312, + "sourceSha256": "5efb8a55c495ea0be28c8845d4fa3dcecdac3e3d770aaa3ae23140da46f8492a", + "symbols": [ + { + "role": "handler", + "symbol": "set_airplane_mode_handler" + }, + { + "role": "request-model", + "symbol": "AirplaneModeRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 255, + "endLine": 257, + "sourceSha256": "5b01ca2ade4b410d12cc9cfe64564d8e29c6f47a1f2357800522cb8f9101134f", + "symbol": "AirplaneModeRequest", + "fields": [ + "enabled" + ] + } + ] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2250, + "endLine": 2312, + "sourceSha256": "5efb8a55c495ea0be28c8845d4fa3dcecdac3e3d770aaa3ae23140da46f8492a", + "symbols": [ + { + "role": "handler", + "symbol": "set_airplane_mode_handler" + }, + { + "role": "response-payload", + "symbol": "AirplaneModeResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 260, + "endLine": 264, + "sourceSha256": "d93cec1a6fe1fc38a8fc2257580f16d2624e2d2ee68971116d6d5065a5feae2a", + "symbol": "AirplaneModeResponse", + "fields": [ + "enabled", + "online", + "powered" + ] + } + ] + } + }, + "POST /api/baseband/restart": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2133, + "endLine": 2159, + "sourceSha256": "ef18782a4b21d5b4ba5c75334f7fb503c697239a06372235139f3844e629f6fc", + "symbols": [ + { + "role": "handler", + "symbol": "restart_baseband_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2133, + "endLine": 2159, + "sourceSha256": "ef18782a4b21d5b4ba5c75334f7fb503c697239a06372235139f3844e629f6fc", + "symbols": [ + { + "role": "handler", + "symbol": "restart_baseband_handler" + }, + { + "role": "response-payload", + "symbol": "BasebandRestartResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 275, + "endLine": 280, + "sourceSha256": "ea551f32f5cc5ebc3d46eb12cab65c2ae1d0a70c32764e4a0e8ad5ade691705e", + "symbol": "BasebandRestartResponse", + "fields": [ + "current_registration", + "running", + "steps" + ] + } + ] + } + }, + "GET /api/baseband/restart/status": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2161, + "endLine": 2169, + "sourceSha256": "736cc766f31d5d714dc45f720bc682f377ded3075b4886ec0cb5eb82b2e65cf5", + "symbols": [ + { + "role": "handler", + "symbol": "get_baseband_restart_status_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "dynamic-json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2161, + "endLine": 2169, + "sourceSha256": "736cc766f31d5d714dc45f720bc682f377ded3075b4886ec0cb5eb82b2e65cf5", + "symbols": [ + { + "role": "handler", + "symbol": "get_baseband_restart_status_handler" + } + ], + "modelEvidence": [] + } + }, + "GET /api/work-mode": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 352, + "endLine": 365, + "sourceSha256": "f2d3dca3f43326f8d0b8da91e01dc98483284a34e2d81ec3602e9fa8e1547ffd", + "symbols": [ + { + "role": "handler", + "symbol": "get_work_mode_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "dynamic-json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 352, + "endLine": 365, + "sourceSha256": "f2d3dca3f43326f8d0b8da91e01dc98483284a34e2d81ec3602e9fa8e1547ffd", + "symbols": [ + { + "role": "handler", + "symbol": "get_work_mode_handler" + } + ], + "modelEvidence": [] + } + }, + "POST /api/work-mode": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 368, + "endLine": 405, + "sourceSha256": "cf17a54e0df74a9d41ddc7d847f7339f4fe2392f4c6c04041bf3cad519931c3c", + "symbols": [ + { + "role": "handler", + "symbol": "set_work_mode_handler" + }, + { + "role": "request-model", + "symbol": "WorkModeRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 57, + "endLine": 61, + "sourceSha256": "6f994606974288f554e5e29ba4b4099604fdae92762bb8f0a38fc6b4c1db69ec", + "symbol": "WorkModeRequest", + "fields": [ + "confirm", + "mode" + ] + } + ] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 368, + "endLine": 405, + "sourceSha256": "cf17a54e0df74a9d41ddc7d847f7339f4fe2392f4c6c04041bf3cad519931c3c", + "symbols": [ + { + "role": "handler", + "symbol": "set_work_mode_handler" + }, + { + "role": "response-payload", + "symbol": "WorkModeResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 64, + "endLine": 67, + "sourceSha256": "c4576a3cc78aec3e1563090898ed107850fa6f0aaeb9ebf1d8b454cc987c9532", + "symbol": "WorkModeResponse", + "fields": [ + "mode", + "worker_running" + ] + } + ] + } + }, + "GET /api/esim/config": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 456, + "endLine": 462, + "sourceSha256": "1be6b526e1a78451fb771cd0ca2ab2013a934d75847157bbf41bd9e2ab359221", + "symbols": [ + { + "role": "handler", + "symbol": "get_esim_config_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "dynamic-json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 456, + "endLine": 462, + "sourceSha256": "1be6b526e1a78451fb771cd0ca2ab2013a934d75847157bbf41bd9e2ab359221", + "symbols": [ + { + "role": "handler", + "symbol": "get_esim_config_handler" + } + ], + "modelEvidence": [] + } + }, + "POST /api/esim/config": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 465, + "endLine": 482, + "sourceSha256": "b497f06687977d9fc9eace182b20a5feb3562b3f6a18583e3e62918cd4e85e79", + "symbols": [ + { + "role": "handler", + "symbol": "set_esim_config_handler" + }, + { + "role": "request-model", + "symbol": "EsimConfig" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/config.rs", + "startLine": 1660, + "endLine": 1665, + "sourceSha256": "07f6979b55ed6cecde92065cd63340436d6968ca3fca994d230ab0725a89bbe4", + "symbol": "EsimConfig", + "fields": [ + "custom_memory_total_kb", + "lpac_path" + ] + } + ] + }, + "responseEvidence": { + "kind": "dynamic-json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 465, + "endLine": 482, + "sourceSha256": "b497f06687977d9fc9eace182b20a5feb3562b3f6a18583e3e62918cd4e85e79", + "symbols": [ + { + "role": "handler", + "symbol": "set_esim_config_handler" + } + ], + "modelEvidence": [] + } + }, + "GET /api/esim/lpac/status": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 408, + "endLine": 416, + "sourceSha256": "a4d5143d1f83089b6f29eda71d7dfc499b47d7260492f453eba31f87a64a8241", + "symbols": [ + { + "role": "handler", + "symbol": "get_esim_lpac_status_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "dynamic-json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 408, + "endLine": 416, + "sourceSha256": "a4d5143d1f83089b6f29eda71d7dfc499b47d7260492f453eba31f87a64a8241", + "symbols": [ + { + "role": "handler", + "symbol": "get_esim_lpac_status_handler" + } + ], + "modelEvidence": [] + } + }, + "POST /api/esim/lpac/repair": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 419, + "endLine": 453, + "sourceSha256": "4d88af6126732e94f424825cd5a3715060973d5aab707f8b9ea9539225199019", + "symbols": [ + { + "role": "handler", + "symbol": "repair_esim_lpac_handler" + }, + { + "role": "request-model", + "symbol": "EsimLpacRepairRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 153, + "endLine": 156, + "sourceSha256": "0345aa3f1d0dce217ddbedcd8776a2d5a774a6dd3c49790eac53c93b59f3296d", + "symbol": "EsimLpacRepairRequest", + "fields": [ + "asset_url", + "proxy_prefix" + ] + } + ] + }, + "responseEvidence": { + "kind": "dynamic-json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 419, + "endLine": 453, + "sourceSha256": "4d88af6126732e94f424825cd5a3715060973d5aab707f8b9ea9539225199019", + "symbols": [ + { + "role": "handler", + "symbol": "repair_esim_lpac_handler" + } + ], + "modelEvidence": [] + } + }, + "GET /api/esim/euicc": { + "requestEvidence": { + "kind": "query", + "sourceFile": "backend/src/handlers.rs", + "startLine": 485, + "endLine": 521, + "sourceSha256": "7bb950b7fe21268d45b57dc7f422cd39b7e542eb76dc6c9a739a83c4f347556e", + "symbols": [ + { + "role": "handler", + "symbol": "get_esim_euicc_handler" + }, + { + "role": "request-model", + "symbol": "HashMap" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "dynamic-json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 485, + "endLine": 521, + "sourceSha256": "7bb950b7fe21268d45b57dc7f422cd39b7e542eb76dc6c9a739a83c4f347556e", + "symbols": [ + { + "role": "handler", + "symbol": "get_esim_euicc_handler" + } + ], + "modelEvidence": [] + } + }, + "GET /api/esim/profiles": { + "requestEvidence": { + "kind": "query", + "sourceFile": "backend/src/handlers.rs", + "startLine": 524, + "endLine": 598, + "sourceSha256": "07f60eddaa9d20ff8239c0083b15207aec94530c2e1edf998dc709e39b125062", + "symbols": [ + { + "role": "handler", + "symbol": "get_esim_profiles_handler" + }, + { + "role": "request-model", + "symbol": "HashMap" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 524, + "endLine": 598, + "sourceSha256": "07f60eddaa9d20ff8239c0083b15207aec94530c2e1edf998dc709e39b125062", + "symbols": [ + { + "role": "handler", + "symbol": "get_esim_profiles_handler" + }, + { + "role": "response-payload", + "symbol": "EsimProfilesResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 135, + "endLine": 137, + "sourceSha256": "1f5b824bc368e322dee089bb37e400598ba9a3432499c115c29e3471e557db5d", + "symbol": "EsimProfilesResponse", + "fields": [ + "profiles" + ] + } + ] + } + }, + "POST /api/esim/profiles": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 803, + "endLine": 1071, + "sourceSha256": "fe41273a15be55b3864e2d8f4dfc65d27686e5cafdc20d972603a5800b7a7043", + "symbols": [ + { + "role": "handler", + "symbol": "download_esim_profile_handler" + }, + { + "role": "request-model", + "symbol": "EsimDownloadRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 174, + "endLine": 179, + "sourceSha256": "c289cfd6af916a1a31987a4b6d2f4012029f40e64cd9a845bb47ab562af93c37", + "symbol": "EsimDownloadRequest", + "fields": [ + "confirmation_code", + "imei", + "matching_id", + "smdp" + ] + } + ] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 803, + "endLine": 1071, + "sourceSha256": "fe41273a15be55b3864e2d8f4dfc65d27686e5cafdc20d972603a5800b7a7043", + "symbols": [ + { + "role": "handler", + "symbol": "download_esim_profile_handler" + }, + { + "role": "response-payload", + "symbol": "EsimCommandResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 70, + "endLine": 81, + "sourceSha256": "c812a61e3ab89df77bea2168552f96f404bdde64d292e780c446ad0e8454ac23", + "symbol": "EsimCommandResponse", + "fields": [ + "action", + "code", + "data", + "msg", + "status" + ] + } + ] + } + }, + "POST /api/esim/profiles/{iccid}/enable": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 601, + "endLine": 726, + "sourceSha256": "d54705546ca3bf2afafe9638d2c33ed198255747e19092a1dd6e1697e691ec2d", + "symbols": [ + { + "role": "handler", + "symbol": "enable_esim_profile_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "dynamic-json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 601, + "endLine": 726, + "sourceSha256": "d54705546ca3bf2afafe9638d2c33ed198255747e19092a1dd6e1697e691ec2d", + "symbols": [ + { + "role": "handler", + "symbol": "enable_esim_profile_handler" + } + ], + "modelEvidence": [] + } + }, + "POST /api/esim/profiles/{iccid}/rename": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 729, + "endLine": 750, + "sourceSha256": "0d5cb68158e1e6c2f656a687eb43be6ad936a8d920e0a18262c20195ea08cb65", + "symbols": [ + { + "role": "handler", + "symbol": "rename_esim_profile_handler" + }, + { + "role": "request-model", + "symbol": "EsimRenameRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 169, + "endLine": 171, + "sourceSha256": "1d610219329178bec27b005b52357e618b3684be48080d3f7b6d726b1146a413", + "symbol": "EsimRenameRequest", + "fields": [ + "name" + ] + } + ] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 729, + "endLine": 750, + "sourceSha256": "0d5cb68158e1e6c2f656a687eb43be6ad936a8d920e0a18262c20195ea08cb65", + "symbols": [ + { + "role": "handler", + "symbol": "rename_esim_profile_handler" + }, + { + "role": "response-payload", + "symbol": "EsimCommandResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 70, + "endLine": 81, + "sourceSha256": "c812a61e3ab89df77bea2168552f96f404bdde64d292e780c446ad0e8454ac23", + "symbol": "EsimCommandResponse", + "fields": [ + "action", + "code", + "data", + "msg", + "status" + ] + } + ] + } + }, + "DELETE /api/esim/profiles/{iccid}": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 753, + "endLine": 780, + "sourceSha256": "8ad25b4826f75d27b2e39e8dee2c2362f47f2c9279ff1bd878ec57e2aa18aaf1", + "symbols": [ + { + "role": "handler", + "symbol": "delete_esim_profile_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "dynamic-json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 753, + "endLine": 780, + "sourceSha256": "8ad25b4826f75d27b2e39e8dee2c2362f47f2c9279ff1bd878ec57e2aa18aaf1", + "symbols": [ + { + "role": "handler", + "symbol": "delete_esim_profile_handler" + } + ], + "modelEvidence": [] + } + }, + "GET /api/calls": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2634, + "endLine": 2655, + "sourceSha256": "21e77021b749876bcd614e999f860c7e6826fc9744733cae9cbc65744b839da1", + "symbols": [ + { + "role": "handler", + "symbol": "get_calls_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2634, + "endLine": 2655, + "sourceSha256": "21e77021b749876bcd614e999f860c7e6826fc9744733cae9cbc65744b839da1", + "symbols": [ + { + "role": "handler", + "symbol": "get_calls_handler" + }, + { + "role": "response-payload", + "symbol": "CallListResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 825, + "endLine": 827, + "sourceSha256": "534ffadc277a3a9cef22e72c7749573ae775f41011220cdc4fa4779266ce713a", + "symbol": "CallListResponse", + "fields": [ + "calls" + ] + } + ] + } + }, + "POST /api/call/dial": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2657, + "endLine": 2689, + "sourceSha256": "c440bb90bb4373b32a57c6c2d40ebbcb8017853f99b0338c444eaa2f19e00d90", + "symbols": [ + { + "role": "handler", + "symbol": "dial_call_handler" + }, + { + "role": "request-model", + "symbol": "MakeCallRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 810, + "endLine": 812, + "sourceSha256": "050178afd522565f4befc4dbe7934efc8dd3ff5588a5b149a70e9b3bfa991b2d", + "symbol": "MakeCallRequest", + "fields": [ + "phone_number" + ] + } + ] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2657, + "endLine": 2689, + "sourceSha256": "c440bb90bb4373b32a57c6c2d40ebbcb8017853f99b0338c444eaa2f19e00d90", + "symbols": [ + { + "role": "handler", + "symbol": "dial_call_handler" + } + ], + "modelEvidence": [] + } + }, + "POST /api/call/hangup": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2691, + "endLine": 2725, + "sourceSha256": "cffdc6b2f949af4d1ad38a29693f8c610824bfc2f950540d43399481aa3bb992", + "symbols": [ + { + "role": "handler", + "symbol": "hangup_call_handler" + }, + { + "role": "request-model", + "symbol": "HangupCallRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 830, + "endLine": 832, + "sourceSha256": "b405a57b92217e0b123abbd6316207a8df9422d90c7178e997e65414fbfcf7be", + "symbol": "HangupCallRequest", + "fields": [ + "path" + ] + } + ] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2691, + "endLine": 2725, + "sourceSha256": "cffdc6b2f949af4d1ad38a29693f8c610824bfc2f950540d43399481aa3bb992", + "symbols": [ + { + "role": "handler", + "symbol": "hangup_call_handler" + } + ], + "modelEvidence": [] + } + }, + "POST /api/call/hangup-all": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2727, + "endLine": 2760, + "sourceSha256": "3af8fab7bb4c04370a679b8dc28c435c60c15bba7f6b68fdccbd06e17698e5b5", + "symbols": [ + { + "role": "handler", + "symbol": "hangup_all_calls_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2727, + "endLine": 2760, + "sourceSha256": "3af8fab7bb4c04370a679b8dc28c435c60c15bba7f6b68fdccbd06e17698e5b5", + "symbols": [ + { + "role": "handler", + "symbol": "hangup_all_calls_handler" + } + ], + "modelEvidence": [] + } + }, + "POST /api/call/answer": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2762, + "endLine": 2789, + "sourceSha256": "cfcda6600d9ba27cb94599a4b0cac37ba4c64a6fb1c85806201e17ff7999c765", + "symbols": [ + { + "role": "handler", + "symbol": "answer_call_handler" + }, + { + "role": "request-model", + "symbol": "HangupCallRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 830, + "endLine": 832, + "sourceSha256": "b405a57b92217e0b123abbd6316207a8df9422d90c7178e997e65414fbfcf7be", + "symbol": "HangupCallRequest", + "fields": [ + "path" + ] + } + ] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2762, + "endLine": 2789, + "sourceSha256": "cfcda6600d9ba27cb94599a4b0cac37ba4c64a6fb1c85806201e17ff7999c765", + "symbols": [ + { + "role": "handler", + "symbol": "answer_call_handler" + } + ], + "modelEvidence": [] + } + }, + "GET /api/call/volume": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2899, + "endLine": 2906, + "sourceSha256": "4f96c358e70ba5b394df486073b9aa4568634b6f17204a1e1beb46eaa65b6113", + "symbols": [ + { + "role": "handler", + "symbol": "get_call_volume_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2899, + "endLine": 2906, + "sourceSha256": "4f96c358e70ba5b394df486073b9aa4568634b6f17204a1e1beb46eaa65b6113", + "symbols": [ + { + "role": "handler", + "symbol": "get_call_volume_handler" + }, + { + "role": "response-payload", + "symbol": "CallVolumeResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 849, + "endLine": 853, + "sourceSha256": "e466c53c32aee5d04caac7101649a6ad7353a6aa32e61db0967d633f672c9d83", + "symbol": "CallVolumeResponse", + "fields": [ + "microphone_volume", + "muted", + "speaker_volume" + ] + } + ] + } + }, + "POST /api/call/volume": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2908, + "endLine": 2922, + "sourceSha256": "9f2e624358e53aaf0adb75e83962b587bd522906d56e597ae9893722505ab582", + "symbols": [ + { + "role": "handler", + "symbol": "set_call_volume_handler" + }, + { + "role": "request-model", + "symbol": "SetCallVolumeRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 856, + "endLine": 860, + "sourceSha256": "8161574cf2af16652244291baedca90926015fcdd9f7fb5c6ca7c10d853d563a", + "symbol": "SetCallVolumeRequest", + "fields": [ + "microphone_volume", + "muted", + "speaker_volume" + ] + } + ] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2908, + "endLine": 2922, + "sourceSha256": "9f2e624358e53aaf0adb75e83962b587bd522906d56e597ae9893722505ab582", + "symbols": [ + { + "role": "handler", + "symbol": "set_call_volume_handler" + }, + { + "role": "response-payload", + "symbol": "CallVolumeResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 849, + "endLine": 853, + "sourceSha256": "e466c53c32aee5d04caac7101649a6ad7353a6aa32e61db0967d633f672c9d83", + "symbol": "CallVolumeResponse", + "fields": [ + "microphone_volume", + "muted", + "speaker_volume" + ] + } + ] + } + }, + "GET /api/call/forwarding": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2924, + "endLine": 2931, + "sourceSha256": "f7916eb4c5c484e5df16a44890308b546788b4f281784dc754c6160047786712", + "symbols": [ + { + "role": "handler", + "symbol": "get_call_forwarding_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2924, + "endLine": 2931, + "sourceSha256": "f7916eb4c5c484e5df16a44890308b546788b4f281784dc754c6160047786712", + "symbols": [ + { + "role": "handler", + "symbol": "get_call_forwarding_handler" + }, + { + "role": "response-payload", + "symbol": "CallForwardingResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 881, + "endLine": 888, + "sourceSha256": "451d94fb326995a6ce078ac4dd06ad42215771d0914b54370b0574f4c515062f", + "symbol": "CallForwardingResponse", + "fields": [ + "forwarding_flag_on_sim", + "voice_busy", + "voice_no_reply", + "voice_no_reply_timeout", + "voice_not_reachable", + "voice_unconditional" + ] + } + ] + } + }, + "POST /api/call/forwarding": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2933, + "endLine": 2943, + "sourceSha256": "ddc8b848bccd96aa0485aed3d8ae828cb0c22f0199fb5e98e22dda87ef866a3d", + "symbols": [ + { + "role": "handler", + "symbol": "set_call_forwarding_handler" + }, + { + "role": "request-model", + "symbol": "SetCallForwardingRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 891, + "endLine": 895, + "sourceSha256": "62bd2c62867b2f8428480220bc1bf7a45c0512ae7ba53b150ac2623481783c39", + "symbol": "SetCallForwardingRequest", + "fields": [ + "forward_type", + "number", + "timeout" + ] + } + ] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2933, + "endLine": 2943, + "sourceSha256": "ddc8b848bccd96aa0485aed3d8ae828cb0c22f0199fb5e98e22dda87ef866a3d", + "symbols": [ + { + "role": "handler", + "symbol": "set_call_forwarding_handler" + }, + { + "role": "response-payload", + "symbol": "CallForwardingResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 881, + "endLine": 888, + "sourceSha256": "451d94fb326995a6ce078ac4dd06ad42215771d0914b54370b0574f4c515062f", + "symbol": "CallForwardingResponse", + "fields": [ + "forwarding_flag_on_sim", + "voice_busy", + "voice_no_reply", + "voice_no_reply_timeout", + "voice_not_reachable", + "voice_unconditional" + ] + } + ] + } + }, + "GET /api/call/settings": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2852, + "endLine": 2866, + "sourceSha256": "893ccecb6c077f7e4c25829113a41bc1d7ddc9766fd813433f0653e32e62f544", + "symbols": [ + { + "role": "handler", + "symbol": "get_call_settings_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2852, + "endLine": 2866, + "sourceSha256": "893ccecb6c077f7e4c25829113a41bc1d7ddc9766fd813433f0653e32e62f544", + "symbols": [ + { + "role": "handler", + "symbol": "get_call_settings_handler" + }, + { + "role": "response-payload", + "symbol": "CallSettingsResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 863, + "endLine": 872, + "sourceSha256": "a344f7efe3cbff4280b06a3fa53af71d51374443e3e6023e66201607f280a6c1", + "symbol": "CallSettingsResponse", + "fields": [ + "called_line_presentation", + "calling_line_presentation", + "calling_line_restriction", + "calling_name_presentation", + "connected_line_presentation", + "connected_line_restriction", + "hide_caller_id", + "voice_call_waiting" + ] + } + ] + } + }, + "POST /api/call/settings": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2868, + "endLine": 2897, + "sourceSha256": "55ab650b3c53b78a3b80606e57d1e96261c5f88c27554c9181f8cd91e212441e", + "symbols": [ + { + "role": "handler", + "symbol": "set_call_settings_handler" + }, + { + "role": "request-model", + "symbol": "SetCallSettingRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 875, + "endLine": 878, + "sourceSha256": "90ab4d69918ab3c7f6de67f338a9c20c2ba26585f54354a890134b8e1c42d68a", + "symbol": "SetCallSettingRequest", + "fields": [ + "property", + "value" + ] + } + ] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2868, + "endLine": 2897, + "sourceSha256": "55ab650b3c53b78a3b80606e57d1e96261c5f88c27554c9181f8cd91e212441e", + "symbols": [ + { + "role": "handler", + "symbol": "set_call_settings_handler" + } + ], + "modelEvidence": [] + } + }, + "GET /api/call/history": { + "requestEvidence": { + "kind": "query", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2791, + "endLine": 2813, + "sourceSha256": "1def94db4c3d848ebba26d285882e36be6d0ce3a61b525d36b120d9b1a11740a", + "symbols": [ + { + "role": "handler", + "symbol": "get_call_history_handler" + }, + { + "role": "request-model", + "symbol": "CallHistoryRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 835, + "endLine": 840, + "sourceSha256": "a51160efe9c06aa423a4d7cfa63a5d6a14bc703dfcbe52e128fc29fe1a35c793", + "symbol": "CallHistoryRequest", + "fields": [ + "limit", + "offset" + ] + } + ] + }, + "responseEvidence": { + "kind": "explicit-return", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2791, + "endLine": 2813, + "sourceSha256": "1def94db4c3d848ebba26d285882e36be6d0ce3a61b525d36b120d9b1a11740a", + "symbols": [ + { + "role": "handler", + "symbol": "get_call_history_handler" + }, + { + "role": "response-payload", + "symbol": "CallHistoryResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 843, + "endLine": 846, + "sourceSha256": "6f6288bea74252c71cbfcd09e19d6acbb992997f15df39b27689151a1dc63e32", + "symbol": "CallHistoryResponse", + "fields": [ + "records", + "stats" + ] + } + ] + } + }, + "DELETE /api/call/history/{id}": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2815, + "endLine": 2832, + "sourceSha256": "defa60d157769310f419da59b4abff5813c6e2ebc790b46d03881e305df44971", + "symbols": [ + { + "role": "handler", + "symbol": "delete_call_history_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "explicit-return", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2815, + "endLine": 2832, + "sourceSha256": "defa60d157769310f419da59b4abff5813c6e2ebc790b46d03881e305df44971", + "symbols": [ + { + "role": "handler", + "symbol": "delete_call_history_handler" + } + ], + "modelEvidence": [] + } + }, + "POST /api/call/history/clear": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2834, + "endLine": 2850, + "sourceSha256": "b0e0002012256b5dba375fdc2f747d07923f191029ff960b09297ddc2a521e73", + "symbols": [ + { + "role": "handler", + "symbol": "clear_call_history_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "explicit-return", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2834, + "endLine": 2850, + "sourceSha256": "b0e0002012256b5dba375fdc2f747d07923f191029ff960b09297ddc2a521e73", + "symbols": [ + { + "role": "handler", + "symbol": "clear_call_history_handler" + } + ], + "modelEvidence": [] + } + }, + "GET /api/ims/status": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2945, + "endLine": 2952, + "sourceSha256": "e533ac5c9e7bc694faa94eaddc7de74e75c53270478480e0c53a1031f5087ba1", + "symbols": [ + { + "role": "handler", + "symbol": "get_ims_status_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2945, + "endLine": 2952, + "sourceSha256": "e533ac5c9e7bc694faa94eaddc7de74e75c53270478480e0c53a1031f5087ba1", + "symbols": [ + { + "role": "handler", + "symbol": "get_ims_status_handler" + }, + { + "role": "response-payload", + "symbol": "ImsStatusResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 898, + "endLine": 902, + "sourceSha256": "46ac721678801d84102ae80a36d3f852c4a331f1fd0087bc215d850f6f90151d", + "symbol": "ImsStatusResponse", + "fields": [ + "registered", + "sms_capable", + "voice_capable" + ] + } + ] + } + }, + "GET /api/voicemail/status": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2954, + "endLine": 2961, + "sourceSha256": "837b8fbc971099b904d594fd605ff70aa3f1242a3f26365db95a479d885b33c3", + "symbols": [ + { + "role": "handler", + "symbol": "get_voicemail_status_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2954, + "endLine": 2961, + "sourceSha256": "837b8fbc971099b904d594fd605ff70aa3f1242a3f26365db95a479d885b33c3", + "symbols": [ + { + "role": "handler", + "symbol": "get_voicemail_status_handler" + }, + { + "role": "response-payload", + "symbol": "VoicemailStatusResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 905, + "endLine": 909, + "sourceSha256": "b37d9745d23f454ec5f24dc41eb62dafc061fceab26fbf847866dbb87113ce1c", + "symbol": "VoicemailStatusResponse", + "fields": [ + "mailbox_number", + "message_count", + "waiting" + ] + } + ] + } + }, + "POST /api/sms/send": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2373, + "endLine": 2404, + "sourceSha256": "6cd7eaa5e1a1f939704cadb96de5c47ac7df8c63a7d87018cf2f0ed8fbcdb156", + "symbols": [ + { + "role": "handler", + "symbol": "send_sms_handler" + }, + { + "role": "request-model", + "symbol": "SendSmsRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 912, + "endLine": 915, + "sourceSha256": "7bb13da27c965b254df83a950901cdeb3af72e86a74d366c9645a5dd30629bd5", + "symbol": "SendSmsRequest", + "fields": [ + "content", + "phone_number" + ] + } + ] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2373, + "endLine": 2404, + "sourceSha256": "6cd7eaa5e1a1f939704cadb96de5c47ac7df8c63a7d87018cf2f0ed8fbcdb156", + "symbols": [ + { + "role": "handler", + "symbol": "send_sms_handler" + } + ], + "modelEvidence": [] + } + }, + "GET /api/sms/list": { + "requestEvidence": { + "kind": "query", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2407, + "endLine": 2434, + "sourceSha256": "abbc14adc02b2f5f0c0cf11a9bbac9ceeddf12871a9be8a00b9e9967923d3576", + "symbols": [ + { + "role": "handler", + "symbol": "get_sms_list_handler" + }, + { + "role": "request-model", + "symbol": "SmsListRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 918, + "endLine": 924, + "sourceSha256": "c6ae086cf90c06566dd6079155b00c082c341a5eddc9d9dc3403799a55896a33", + "symbol": "SmsListRequest", + "fields": [ + "direction", + "limit", + "offset" + ] + } + ] + }, + "responseEvidence": { + "kind": "explicit-return", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2407, + "endLine": 2434, + "sourceSha256": "abbc14adc02b2f5f0c0cf11a9bbac9ceeddf12871a9be8a00b9e9967923d3576", + "symbols": [ + { + "role": "handler", + "symbol": "get_sms_list_handler" + }, + { + "role": "response-payload", + "symbol": "SmsListResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 946, + "endLine": 948, + "sourceSha256": "207d772eb68ac5872f189b8a88b9e3c8cb74130337d7aaae2f631ae0f8a5b3fa", + "symbol": "SmsListResponse", + "fields": [ + "messages" + ] + } + ] + } + }, + "GET /api/sms/conversation": { + "requestEvidence": { + "kind": "query", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2437, + "endLine": 2458, + "sourceSha256": "e0747d062889fe8caf7b16d6f394c1a3a4f10148f8df3e2b0b09c80e345bb3da", + "symbols": [ + { + "role": "handler", + "symbol": "get_sms_conversation_handler" + }, + { + "role": "request-model", + "symbol": "SmsConversationRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 927, + "endLine": 931, + "sourceSha256": "f13373dc53097cfc6a311162fbb412fce9799880ee71bced82a70c9c2ea64dd4", + "symbol": "SmsConversationRequest", + "fields": [ + "limit", + "phone_number" + ] + } + ] + }, + "responseEvidence": { + "kind": "explicit-return", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2437, + "endLine": 2458, + "sourceSha256": "e0747d062889fe8caf7b16d6f394c1a3a4f10148f8df3e2b0b09c80e345bb3da", + "symbols": [ + { + "role": "handler", + "symbol": "get_sms_conversation_handler" + }, + { + "role": "response-payload", + "symbol": "SmsListResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 946, + "endLine": 948, + "sourceSha256": "207d772eb68ac5872f189b8a88b9e3c8cb74130337d7aaae2f631ae0f8a5b3fa", + "symbol": "SmsListResponse", + "fields": [ + "messages" + ] + } + ] + } + }, + "GET /api/sms/stats": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2461, + "endLine": 2477, + "sourceSha256": "bd62f399815cb0d1f41cf895ce32e921dd10bc8cde5b30ed95ba9b46a629b9eb", + "symbols": [ + { + "role": "handler", + "symbol": "get_sms_stats_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "explicit-return", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2461, + "endLine": 2477, + "sourceSha256": "bd62f399815cb0d1f41cf895ce32e921dd10bc8cde5b30ed95ba9b46a629b9eb", + "symbols": [ + { + "role": "handler", + "symbol": "get_sms_stats_handler" + }, + { + "role": "response-payload", + "symbol": "SmsStatsResponse" + } + ], + "modelEvidence": [] + } + }, + "POST /api/sms/batch-delete": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2551, + "endLine": 2578, + "sourceSha256": "94c5c834874012034e8bcd6ba0b03f8cd24fb283c3682129568bcbada59d1609", + "symbols": [ + { + "role": "handler", + "symbol": "delete_sms_batch_handler" + }, + { + "role": "request-model", + "symbol": "SmsBatchDeleteRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 934, + "endLine": 939, + "sourceSha256": "3e952d11ae6ff6d62408df163ea1827ffd65bf064b9ccacbe408366350980f2f", + "symbol": "SmsBatchDeleteRequest", + "fields": [ + "ids", + "phone_numbers" + ] + } + ] + }, + "responseEvidence": { + "kind": "explicit-return", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2551, + "endLine": 2578, + "sourceSha256": "94c5c834874012034e8bcd6ba0b03f8cd24fb283c3682129568bcbada59d1609", + "symbols": [ + { + "role": "handler", + "symbol": "delete_sms_batch_handler" + } + ], + "modelEvidence": [] + } + }, + "DELETE /api/sms/conversation/{phone_number}": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2528, + "endLine": 2548, + "sourceSha256": "8abd673088da872031588a6449bba27fc95aac6d74a418eed143adbd1fc8166a", + "symbols": [ + { + "role": "handler", + "symbol": "delete_sms_conversation_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "explicit-return", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2528, + "endLine": 2548, + "sourceSha256": "8abd673088da872031588a6449bba27fc95aac6d74a418eed143adbd1fc8166a", + "symbols": [ + { + "role": "handler", + "symbol": "delete_sms_conversation_handler" + } + ], + "modelEvidence": [] + } + }, + "DELETE /api/sms/message/{id}": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2508, + "endLine": 2525, + "sourceSha256": "8edc9f89854b5c8d8150cb04d5d26935c5bf3131db2cc459a32966c24c6c37f6", + "symbols": [ + { + "role": "handler", + "symbol": "delete_sms_message_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "explicit-return", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2508, + "endLine": 2525, + "sourceSha256": "8edc9f89854b5c8d8150cb04d5d26935c5bf3131db2cc459a32966c24c6c37f6", + "symbols": [ + { + "role": "handler", + "symbol": "delete_sms_message_handler" + } + ], + "modelEvidence": [] + } + }, + "POST /api/sms/clear": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2480, + "endLine": 2505, + "sourceSha256": "8c536214c43b65fc5ae19a81c26c7f3b007d6da6eada9f3908f0f9ddcd81bb57", + "symbols": [ + { + "role": "handler", + "symbol": "clear_sms_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "explicit-return", + "sourceFile": "backend/src/handlers.rs", + "startLine": 2480, + "endLine": 2505, + "sourceSha256": "8c536214c43b65fc5ae19a81c26c7f3b007d6da6eada9f3908f0f9ddcd81bb57", + "symbols": [ + { + "role": "handler", + "symbol": "clear_sms_handler" + } + ], + "modelEvidence": [] + } + }, + "GET /api/stats": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3272, + "endLine": 3288, + "sourceSha256": "94834f33d10be4b67c6dbc13d948ea17094235847d6e6cab92f426ff56f8af51", + "symbols": [ + { + "role": "handler", + "symbol": "get_system_stats" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "dynamic-json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3272, + "endLine": 3288, + "sourceSha256": "94834f33d10be4b67c6dbc13d948ea17094235847d6e6cab92f426ff56f8af51", + "symbols": [ + { + "role": "handler", + "symbol": "get_system_stats" + } + ], + "modelEvidence": [] + } + }, + "GET /api/stats/cpu": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3291, + "endLine": 3302, + "sourceSha256": "9f24be45c5c8df7a153d7542ec26c95a0a92af09ff5246e3650d985adb634b77", + "symbols": [ + { + "role": "handler", + "symbol": "get_cpu_info" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3291, + "endLine": 3302, + "sourceSha256": "9f24be45c5c8df7a153d7542ec26c95a0a92af09ff5246e3650d985adb634b77", + "symbols": [ + { + "role": "handler", + "symbol": "get_cpu_info" + }, + { + "role": "response-payload", + "symbol": "CpuInfo" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 668, + "endLine": 674, + "sourceSha256": "18f0a5679fc0be1579e6feb2087ce42d2a39de98d64e48d2a2026e0b5fab5b4e", + "symbol": "CpuInfo", + "fields": [ + "core_count", + "cores", + "hardware", + "model_name", + "serial" + ] + } + ] + } + }, + "GET /api/connectivity": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3305, + "endLine": 3322, + "sourceSha256": "42ba5f81f052b9923867ee194593504b9f390c1972b08483e4bec85c017303a4", + "symbols": [ + { + "role": "handler", + "symbol": "get_connectivity_check" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "explicit-return", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3305, + "endLine": 3322, + "sourceSha256": "42ba5f81f052b9923867ee194593504b9f390c1972b08483e4bec85c017303a4", + "symbols": [ + { + "role": "handler", + "symbol": "get_connectivity_check" + }, + { + "role": "response-payload", + "symbol": "ConnectivityCheckResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 501, + "endLine": 504, + "sourceSha256": "67f9945179b4738841849a49ab5dc4cf0c60cf6ff89efe261c8fd1946e055ffc", + "symbol": "ConnectivityCheckResponse", + "fields": [ + "ipv4", + "ipv6" + ] + } + ] + } + }, + "POST /api/system/reboot": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3381, + "endLine": 3406, + "sourceSha256": "362737633fd01332829b2623f8aa0bd398846a3648fb431f70c161ee1bb6fe1e", + "symbols": [ + { + "role": "handler", + "symbol": "system_reboot" + }, + { + "role": "request-model", + "symbol": "SystemRebootRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 422, + "endLine": 425, + "sourceSha256": "a619d31a2a25801c7160c8e7e7e044b75c416345782468eb282f9c1de09fa3ec", + "symbol": "SystemRebootRequest", + "fields": [ + "delay_seconds" + ] + } + ] + }, + "responseEvidence": { + "kind": "dynamic-json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3381, + "endLine": 3406, + "sourceSha256": "362737633fd01332829b2623f8aa0bd398846a3648fb431f70c161ee1bb6fe1e", + "symbols": [ + { + "role": "handler", + "symbol": "system_reboot" + } + ], + "modelEvidence": [] + } + }, + "POST /api/service/restart": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3578, + "endLine": 3601, + "sourceSha256": "712a81c6cca94e0cd2911c324c35cfbb27ed88688f8a119783105e912bceec8b", + "symbols": [ + { + "role": "handler", + "symbol": "restart_service_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "dynamic-json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3578, + "endLine": 3601, + "sourceSha256": "712a81c6cca94e0cd2911c324c35cfbb27ed88688f8a119783105e912bceec8b", + "symbols": [ + { + "role": "handler", + "symbol": "restart_service_handler" + } + ], + "modelEvidence": [] + } + }, + "GET /api/notifications/config": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3641, + "endLine": 3652, + "sourceSha256": "96344b8b2d1b881879c78c33a954a6a7af4de9aa59ffc1d1ef77a4ebc980e277", + "symbols": [ + { + "role": "handler", + "symbol": "get_notification_config_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "explicit-return", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3641, + "endLine": 3652, + "sourceSha256": "96344b8b2d1b881879c78c33a954a6a7af4de9aa59ffc1d1ef77a4ebc980e277", + "symbols": [ + { + "role": "handler", + "symbol": "get_notification_config_handler" + }, + { + "role": "response-payload", + "symbol": "NotificationConfig" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/config.rs", + "startLine": 548, + "endLine": 557, + "sourceSha256": "7c760845eebafb3ac64f3ff4d7df29564fcab9438ebdb1a248f34cd479d70b25", + "symbol": "NotificationConfig", + "fields": [ + "channels", + "log_cleanup", + "rules", + "version" + ] + } + ] + } + }, + "POST /api/notifications/config": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3655, + "endLine": 3672, + "sourceSha256": "5fc4fbfff68050e0887b051bb111a78b3d3805dfc32b685414f4178333285f3e", + "symbols": [ + { + "role": "handler", + "symbol": "set_notification_config_handler" + }, + { + "role": "request-model", + "symbol": "NotificationConfig" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/config.rs", + "startLine": 548, + "endLine": 557, + "sourceSha256": "7c760845eebafb3ac64f3ff4d7df29564fcab9438ebdb1a248f34cd479d70b25", + "symbol": "NotificationConfig", + "fields": [ + "channels", + "log_cleanup", + "rules", + "version" + ] + } + ] + }, + "responseEvidence": { + "kind": "explicit-return", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3655, + "endLine": 3672, + "sourceSha256": "5fc4fbfff68050e0887b051bb111a78b3d3805dfc32b685414f4178333285f3e", + "symbols": [ + { + "role": "handler", + "symbol": "set_notification_config_handler" + } + ], + "modelEvidence": [] + } + }, + "POST /api/notifications/test/{channel}": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3675, + "endLine": 3704, + "sourceSha256": "df28427afab1724aa28b0e28f39b7707778422f086b9ca9bd8ac8ec8e5c909e5", + "symbols": [ + { + "role": "handler", + "symbol": "test_notification_channel_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "explicit-return", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3675, + "endLine": 3704, + "sourceSha256": "df28427afab1724aa28b0e28f39b7707778422f086b9ca9bd8ac8ec8e5c909e5", + "symbols": [ + { + "role": "handler", + "symbol": "test_notification_channel_handler" + }, + { + "role": "response-payload", + "symbol": "WebhookTestResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 953, + "endLine": 956, + "sourceSha256": "03fcc119e593f9b4bf2c21e40911c3da6b4fb7892c530c3885e760ae0a28d89c", + "symbol": "WebhookTestResponse", + "fields": [ + "message", + "success" + ] + } + ] + } + }, + "GET /api/notifications/logs": { + "requestEvidence": { + "kind": "query", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3709, + "endLine": 3734, + "sourceSha256": "da933f5e679cb9e14580b82b75a4caae71e26db4b2e9f57f32b99c1d472bf005", + "symbols": [ + { + "role": "handler", + "symbol": "get_notification_logs_handler" + }, + { + "role": "request-model", + "symbol": "NotificationLogQuery" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/handlers.rs", + "startLine": 3607, + "endLine": 3622, + "sourceSha256": "37621ba31c70d9e5ec5e3a2b2ac6fcf4467ab1c404445e17708816d43e3fc62b", + "symbol": "NotificationLogQuery", + "fields": [ + "end_date", + "event_type", + "limit", + "offset", + "q", + "start_date", + "status" + ] + } + ] + }, + "responseEvidence": { + "kind": "explicit-return", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3709, + "endLine": 3734, + "sourceSha256": "da933f5e679cb9e14580b82b75a4caae71e26db4b2e9f57f32b99c1d472bf005", + "symbols": [ + { + "role": "handler", + "symbol": "get_notification_logs_handler" + }, + { + "role": "response-payload", + "symbol": "NotificationLogsResponse" + } + ], + "modelEvidence": [] + } + }, + "POST /api/notifications/logs/clear": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3737, + "endLine": 3760, + "sourceSha256": "04de36a4268a3ab6f30354da995a73fb44689d28a2dcd4945548b3b1a89c64d3", + "symbols": [ + { + "role": "handler", + "symbol": "clear_notification_logs_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "explicit-return", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3737, + "endLine": 3760, + "sourceSha256": "04de36a4268a3ab6f30354da995a73fb44689d28a2dcd4945548b3b1a89c64d3", + "symbols": [ + { + "role": "handler", + "symbol": "clear_notification_logs_handler" + } + ], + "modelEvidence": [] + } + }, + "GET /api/notifications/queue": { + "requestEvidence": { + "kind": "query", + "sourceFile": "backend/src/notification_queue.rs", + "startLine": 24, + "endLine": 41, + "sourceSha256": "babc88061bcb3386c8394818ccafe784c4c3fd32dd3626370faa0cf0f7652f86", + "symbols": [ + { + "role": "handler", + "symbol": "get_notification_queue_handler" + }, + { + "role": "request-model", + "symbol": "NotificationQueueQuery" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/notification_queue.rs", + "startLine": 14, + "endLine": 17, + "sourceSha256": "82679b6e977664dd33df66758613aca8906f01b2dec32e05867bca1139ffc301", + "symbol": "NotificationQueueQuery", + "fields": [ + "limit" + ] + } + ] + }, + "responseEvidence": { + "kind": "explicit-return", + "sourceFile": "backend/src/notification_queue.rs", + "startLine": 24, + "endLine": 41, + "sourceSha256": "babc88061bcb3386c8394818ccafe784c4c3fd32dd3626370faa0cf0f7652f86", + "symbols": [ + { + "role": "handler", + "symbol": "get_notification_queue_handler" + }, + { + "role": "response-payload", + "symbol": "NotificationQueueResponse" + } + ], + "modelEvidence": [] + } + }, + "POST /api/notifications/queue/retry-all": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/notification_queue.rs", + "startLine": 84, + "endLine": 100, + "sourceSha256": "f58e1be1cbf6a022663038bdbc76ec7c6df7c0bbcba6f13ea16faf25f03dcd34", + "symbols": [ + { + "role": "handler", + "symbol": "retry_all_notification_queue_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "explicit-return", + "sourceFile": "backend/src/notification_queue.rs", + "startLine": 84, + "endLine": 100, + "sourceSha256": "f58e1be1cbf6a022663038bdbc76ec7c6df7c0bbcba6f13ea16faf25f03dcd34", + "symbols": [ + { + "role": "handler", + "symbol": "retry_all_notification_queue_handler" + } + ], + "modelEvidence": [] + } + }, + "POST /api/notifications/queue/clear": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/notification_queue.rs", + "startLine": 103, + "endLine": 119, + "sourceSha256": "2501068f6741e02ed9dfb0475191acfa80ccc81f3cf9b8d2152920c78b7006f1", + "symbols": [ + { + "role": "handler", + "symbol": "clear_notification_queue_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "explicit-return", + "sourceFile": "backend/src/notification_queue.rs", + "startLine": 103, + "endLine": 119, + "sourceSha256": "2501068f6741e02ed9dfb0475191acfa80ccc81f3cf9b8d2152920c78b7006f1", + "symbols": [ + { + "role": "handler", + "symbol": "clear_notification_queue_handler" + } + ], + "modelEvidence": [] + } + }, + "DELETE /api/notifications/queue/{id}": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/notification_queue.rs", + "startLine": 64, + "endLine": 81, + "sourceSha256": "6b845ad2da8f319d8cfa2cb208361901c5a3e852027d9cb96bd54bcd5b27b034", + "symbols": [ + { + "role": "handler", + "symbol": "delete_notification_queue_item_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "explicit-return", + "sourceFile": "backend/src/notification_queue.rs", + "startLine": 64, + "endLine": 81, + "sourceSha256": "6b845ad2da8f319d8cfa2cb208361901c5a3e852027d9cb96bd54bcd5b27b034", + "symbols": [ + { + "role": "handler", + "symbol": "delete_notification_queue_item_handler" + } + ], + "modelEvidence": [] + } + }, + "POST /api/notifications/queue/{id}/retry": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/notification_queue.rs", + "startLine": 44, + "endLine": 61, + "sourceSha256": "068be5777b9d8f78a7d72c2368b788ab23e6ae1de4d4ab110596783be869d967", + "symbols": [ + { + "role": "handler", + "symbol": "retry_notification_queue_item_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "explicit-return", + "sourceFile": "backend/src/notification_queue.rs", + "startLine": 44, + "endLine": 61, + "sourceSha256": "068be5777b9d8f78a7d72c2368b788ab23e6ae1de4d4ab110596783be869d967", + "symbols": [ + { + "role": "handler", + "symbol": "retry_notification_queue_item_handler" + } + ], + "modelEvidence": [] + } + }, + "GET /api/automation/config": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3968, + "endLine": 3979, + "sourceSha256": "eed75ffb9545cf5e08d2bc7ae01ead9830e9457842ce87613f8bdbad8d2b3bba", + "symbols": [ + { + "role": "handler", + "symbol": "get_automation_config_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "explicit-return", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3968, + "endLine": 3979, + "sourceSha256": "eed75ffb9545cf5e08d2bc7ae01ead9830e9457842ce87613f8bdbad8d2b3bba", + "symbols": [ + { + "role": "handler", + "symbol": "get_automation_config_handler" + }, + { + "role": "response-payload", + "symbol": "AutomationConfig" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/config.rs", + "startLine": 1394, + "endLine": 1399, + "sourceSha256": "0442bb2cb71a2f10ff3622a2e82c95100653a25abde4082aff2bad437f7ac440", + "symbol": "AutomationConfig", + "fields": [ + "enabled", + "tasks" + ] + } + ] + } + }, + "POST /api/automation/config": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3982, + "endLine": 3999, + "sourceSha256": "7f324660f7624a52d5521b4d2a15dbb099cf5a4c518b662a0f7d776f68a33c97", + "symbols": [ + { + "role": "handler", + "symbol": "set_automation_config_handler" + }, + { + "role": "request-model", + "symbol": "AutomationConfig" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/config.rs", + "startLine": 1394, + "endLine": 1399, + "sourceSha256": "0442bb2cb71a2f10ff3622a2e82c95100653a25abde4082aff2bad437f7ac440", + "symbol": "AutomationConfig", + "fields": [ + "enabled", + "tasks" + ] + } + ] + }, + "responseEvidence": { + "kind": "explicit-return", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3982, + "endLine": 3999, + "sourceSha256": "7f324660f7624a52d5521b4d2a15dbb099cf5a4c518b662a0f7d776f68a33c97", + "symbols": [ + { + "role": "handler", + "symbol": "set_automation_config_handler" + } + ], + "modelEvidence": [] + } + }, + "GET /api/automation/logs": { + "requestEvidence": { + "kind": "query", + "sourceFile": "backend/src/handlers.rs", + "startLine": 4002, + "endLine": 4027, + "sourceSha256": "b6cacc95a374e31ad4fb1141d8a1b002ddb6fb642b6300a501830744ad0a587d", + "symbols": [ + { + "role": "handler", + "symbol": "get_automation_logs_handler" + }, + { + "role": "request-model", + "symbol": "AutomationLogQuery" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/handlers.rs", + "startLine": 3938, + "endLine": 3953, + "sourceSha256": "60ffd4c72f57c22da74839546536006420dc0d773d864ee0bd96461e22e7bf91", + "symbol": "AutomationLogQuery", + "fields": [ + "end_date", + "limit", + "offset", + "q", + "start_date", + "status", + "task_type" + ] + } + ] + }, + "responseEvidence": { + "kind": "explicit-return", + "sourceFile": "backend/src/handlers.rs", + "startLine": 4002, + "endLine": 4027, + "sourceSha256": "b6cacc95a374e31ad4fb1141d8a1b002ddb6fb642b6300a501830744ad0a587d", + "symbols": [ + { + "role": "handler", + "symbol": "get_automation_logs_handler" + }, + { + "role": "response-payload", + "symbol": "AutomationLogsResponse" + } + ], + "modelEvidence": [] + } + }, + "POST /api/automation/logs/clear": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 4030, + "endLine": 4053, + "sourceSha256": "0912300e74c831ac108f2696eb28a65b5861cd508498330a9a91add6f6781d7e", + "symbols": [ + { + "role": "handler", + "symbol": "clear_automation_logs_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "explicit-return", + "sourceFile": "backend/src/handlers.rs", + "startLine": 4030, + "endLine": 4053, + "sourceSha256": "0912300e74c831ac108f2696eb28a65b5861cd508498330a9a91add6f6781d7e", + "symbols": [ + { + "role": "handler", + "symbol": "clear_automation_logs_handler" + } + ], + "modelEvidence": [] + } + }, + "POST /api/automation/test/{task_id}": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 4056, + "endLine": 4146, + "sourceSha256": "091db790a51ab1855cc1d96b0a07e5c1fc6570102050382abab2535ea5d2034e", + "symbols": [ + { + "role": "handler", + "symbol": "test_automation_task_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "explicit-return", + "sourceFile": "backend/src/handlers.rs", + "startLine": 4056, + "endLine": 4146, + "sourceSha256": "091db790a51ab1855cc1d96b0a07e5c1fc6570102050382abab2535ea5d2034e", + "symbols": [ + { + "role": "handler", + "symbol": "test_automation_task_handler" + } + ], + "modelEvidence": [] + } + }, + "GET /api/ota/status": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3763, + "endLine": 3769, + "sourceSha256": "89d8afa94cbad60c4845764679734bf5ef62b1bdf0f64f4e905740d978cce5b0", + "symbols": [ + { + "role": "handler", + "symbol": "get_ota_status_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "dynamic-json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3763, + "endLine": 3769, + "sourceSha256": "89d8afa94cbad60c4845764679734bf5ef62b1bdf0f64f4e905740d978cce5b0", + "symbols": [ + { + "role": "handler", + "symbol": "get_ota_status_handler" + } + ], + "modelEvidence": [] + } + }, + "POST /api/ota/upload": { + "requestEvidence": { + "kind": "bytes", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3772, + "endLine": 3792, + "sourceSha256": "becfb7dc7e9c1355976abcc802d12f03c06d05d035db2203516fee0cc0b12a74", + "symbols": [ + { + "role": "handler", + "symbol": "upload_ota_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3772, + "endLine": 3792, + "sourceSha256": "becfb7dc7e9c1355976abcc802d12f03c06d05d035db2203516fee0cc0b12a74", + "symbols": [ + { + "role": "handler", + "symbol": "upload_ota_handler" + }, + { + "role": "response-payload", + "symbol": "OtaUploadResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 980, + "endLine": 983, + "sourceSha256": "17518f664e9ac5b281203ddda370fb8dd291c31fe863ec2ffad660e9f092518b", + "symbol": "OtaUploadResponse", + "fields": [ + "meta", + "validation" + ] + } + ] + } + }, + "POST /api/ota/latest-release": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3795, + "endLine": 3825, + "sourceSha256": "61083109860620e593336906b5b08360b7694f3553ab6859445166a52cef0314", + "symbols": [ + { + "role": "handler", + "symbol": "get_latest_ota_release_handler" + }, + { + "role": "request-model", + "symbol": "OtaOnlinePrepareRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 986, + "endLine": 988, + "sourceSha256": "e32ec913a9464b6cde62845a55cdd4842e395dae5875484394521b3c85f423d0", + "symbol": "OtaOnlinePrepareRequest", + "fields": [ + "proxy_prefix" + ] + } + ] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3795, + "endLine": 3825, + "sourceSha256": "61083109860620e593336906b5b08360b7694f3553ab6859445166a52cef0314", + "symbols": [ + { + "role": "handler", + "symbol": "get_latest_ota_release_handler" + }, + { + "role": "response-payload", + "symbol": "OtaLatestReleaseResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 998, + "endLine": 1006, + "sourceSha256": "81fb4dc12d778b756679dd951f42947f9356d9a15f14e9178c417e7882e3e7f5", + "symbol": "OtaLatestReleaseResponse", + "fields": [ + "assets", + "body", + "html_url", + "name", + "published_at", + "tag_name", + "target_commitish" + ] + } + ] + } + }, + "POST /api/ota/online-prepare": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3828, + "endLine": 3889, + "sourceSha256": "6d70f80714b7f00461ba8eb4a3dc5c7f6056590bed50162031e7c0af8e5371e2", + "symbols": [ + { + "role": "handler", + "symbol": "prepare_online_ota_handler" + }, + { + "role": "request-model", + "symbol": "OtaOnlinePrepareRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 986, + "endLine": 988, + "sourceSha256": "e32ec913a9464b6cde62845a55cdd4842e395dae5875484394521b3c85f423d0", + "symbol": "OtaOnlinePrepareRequest", + "fields": [ + "proxy_prefix" + ] + } + ] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3828, + "endLine": 3889, + "sourceSha256": "6d70f80714b7f00461ba8eb4a3dc5c7f6056590bed50162031e7c0af8e5371e2", + "symbols": [ + { + "role": "handler", + "symbol": "prepare_online_ota_handler" + }, + { + "role": "response-payload", + "symbol": "OtaUploadResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 980, + "endLine": 983, + "sourceSha256": "17518f664e9ac5b281203ddda370fb8dd291c31fe863ec2ffad660e9f092518b", + "symbol": "OtaUploadResponse", + "fields": [ + "meta", + "validation" + ] + } + ] + } + }, + "POST /api/ota/apply": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3892, + "endLine": 3911, + "sourceSha256": "46c24f90fdb71e52316e32c93e6c37265d79dc4bb2076bbed9c97b92517c3d03", + "symbols": [ + { + "role": "handler", + "symbol": "apply_ota_handler" + }, + { + "role": "request-model", + "symbol": "OtaApplyRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/models.rs", + "startLine": 1030, + "endLine": 1033, + "sourceSha256": "adbbb9f4fe67cbdde1a4b0d2a3077b6db69e1e568b14b633ab68a0de15d24833", + "symbol": "OtaApplyRequest", + "fields": [ + "restart_now" + ] + } + ] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3892, + "endLine": 3911, + "sourceSha256": "46c24f90fdb71e52316e32c93e6c37265d79dc4bb2076bbed9c97b92517c3d03", + "symbols": [ + { + "role": "handler", + "symbol": "apply_ota_handler" + } + ], + "modelEvidence": [] + } + }, + "POST /api/ota/cancel": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3914, + "endLine": 3931, + "sourceSha256": "9085b81dc3f8588654d7b258de241d29832afa2c40a9390bbbe9245c069a1603", + "symbols": [ + { + "role": "handler", + "symbol": "cancel_ota_handler" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/handlers.rs", + "startLine": 3914, + "endLine": 3931, + "sourceSha256": "9085b81dc3f8588654d7b258de241d29832afa2c40a9390bbbe9245c069a1603", + "symbols": [ + { + "role": "handler", + "symbol": "cancel_ota_handler" + } + ], + "modelEvidence": [] + } + }, + "POST /api/auth/password": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/auth.rs", + "startLine": 491, + "endLine": 553, + "sourceSha256": "ec7372792586fd2ab24eda6fc2fc008b04c567045c6dfc54f9ae24530ce3b113", + "symbols": [ + { + "role": "handler", + "symbol": "change_password" + }, + { + "role": "request-model", + "symbol": "ChangePasswordRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/auth.rs", + "startLine": 59, + "endLine": 61, + "sourceSha256": "b948627546180642fbd2298ec4aed4a2a76d8bab8d8252dcef2058fd410cc904", + "symbol": "ChangePasswordRequest", + "fields": [ + "new_password" + ] + } + ] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/auth.rs", + "startLine": 491, + "endLine": 553, + "sourceSha256": "ec7372792586fd2ab24eda6fc2fc008b04c567045c6dfc54f9ae24530ce3b113", + "symbols": [ + { + "role": "handler", + "symbol": "change_password" + }, + { + "role": "response-payload", + "symbol": "Value" + } + ], + "modelEvidence": [] + } + }, + "GET /api/auth/settings": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/auth.rs", + "startLine": 555, + "endLine": 570, + "sourceSha256": "035bdb5afbefdcb84d93a6f58f94ace658cfd041bea5ac3f41f1d6d32d4eca1f", + "symbols": [ + { + "role": "handler", + "symbol": "get_settings" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "explicit-return", + "sourceFile": "backend/src/auth.rs", + "startLine": 555, + "endLine": 570, + "sourceSha256": "035bdb5afbefdcb84d93a6f58f94ace658cfd041bea5ac3f41f1d6d32d4eca1f", + "symbols": [ + { + "role": "handler", + "symbol": "get_settings" + }, + { + "role": "response-payload", + "symbol": "AuthSettingsResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/auth.rs", + "startLine": 71, + "endLine": 74, + "sourceSha256": "5b0146ad305d3c4cd51ec61ae6950dd8d70ae593f39bb5051fcc112a426d80e4", + "symbol": "AuthSettingsResponse", + "fields": [ + "configured", + "settings" + ] + } + ] + } + }, + "POST /api/auth/settings": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/auth.rs", + "startLine": 572, + "endLine": 640, + "sourceSha256": "10b564c9437defd56e62bb46e622cce512d3cd42ad8fee67699697a7828987e8", + "symbols": [ + { + "role": "handler", + "symbol": "set_settings" + }, + { + "role": "request-model", + "symbol": "SecurityConfig" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/config.rs", + "startLine": 615, + "endLine": 630, + "sourceSha256": "46e713f4967fd16a734acb602a92f9baa202d90ca57f506ae4591d8f6b645637", + "symbol": "SecurityConfig", + "fields": [ + "idle_timeout_seconds", + "password_min_length", + "password_protection_enabled", + "password_require_digits", + "password_require_letters", + "password_require_symbols", + "session_ttl_seconds" + ] + } + ] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/auth.rs", + "startLine": 572, + "endLine": 640, + "sourceSha256": "10b564c9437defd56e62bb46e622cce512d3cd42ad8fee67699697a7828987e8", + "symbols": [ + { + "role": "handler", + "symbol": "set_settings" + }, + { + "role": "response-payload", + "symbol": "Value" + } + ], + "modelEvidence": [] + } + }, + "GET /api/health": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/handlers.rs", + "startLine": 65, + "endLine": 75, + "sourceSha256": "e169391fd5f376b8602c2164d0070744964b009f367d2f42c8832b9f98faad25", + "symbols": [ + { + "role": "handler", + "symbol": "health_check" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "dynamic-json", + "sourceFile": "backend/src/handlers.rs", + "startLine": 65, + "endLine": 75, + "sourceSha256": "e169391fd5f376b8602c2164d0070744964b009f367d2f42c8832b9f98faad25", + "symbols": [ + { + "role": "handler", + "symbol": "health_check" + } + ], + "modelEvidence": [] + } + }, + "GET /api/auth/status": { + "requestEvidence": { + "kind": "path", + "sourceFile": "backend/src/auth.rs", + "startLine": 345, + "endLine": 364, + "sourceSha256": "0ee683548f9f803687e7162cd5c596b09e36ff0adad03a5cb63c2067ece24aa3", + "symbols": [ + { + "role": "handler", + "symbol": "status" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "explicit-return", + "sourceFile": "backend/src/auth.rs", + "startLine": 345, + "endLine": 364, + "sourceSha256": "0ee683548f9f803687e7162cd5c596b09e36ff0adad03a5cb63c2067ece24aa3", + "symbols": [ + { + "role": "handler", + "symbol": "status" + }, + { + "role": "response-payload", + "symbol": "AuthStatusResponse" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/auth.rs", + "startLine": 64, + "endLine": 68, + "sourceSha256": "155fbb4f48fd81dbf141abb81a3e9b74b8aea1299f174b5b147353ba8a1aa388", + "symbol": "AuthStatusResponse", + "fields": [ + "authenticated", + "configured", + "settings" + ] + } + ] + } + }, + "POST /api/auth/setup": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/auth.rs", + "startLine": 366, + "endLine": 427, + "sourceSha256": "1c0c5eb2cc77326de57062361dfe9c499aad351d8d3bbd101844c5cd0be43a6f", + "symbols": [ + { + "role": "handler", + "symbol": "setup" + }, + { + "role": "request-model", + "symbol": "LoginRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/auth.rs", + "startLine": 54, + "endLine": 56, + "sourceSha256": "a333d129da6264b0c84443151fff194aaf69b1c4d1889f6044f3acb8aa0e3536", + "symbol": "LoginRequest", + "fields": [ + "password" + ] + } + ] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/auth.rs", + "startLine": 366, + "endLine": 427, + "sourceSha256": "1c0c5eb2cc77326de57062361dfe9c499aad351d8d3bbd101844c5cd0be43a6f", + "symbols": [ + { + "role": "handler", + "symbol": "setup" + }, + { + "role": "response-payload", + "symbol": "Value" + } + ], + "modelEvidence": [] + } + }, + "POST /api/auth/login": { + "requestEvidence": { + "kind": "json", + "sourceFile": "backend/src/auth.rs", + "startLine": 429, + "endLine": 489, + "sourceSha256": "defd9444410e5b124ab0d621e7faf70bae335363e7e287738273e9e06cc151a1", + "symbols": [ + { + "role": "handler", + "symbol": "login" + }, + { + "role": "request-model", + "symbol": "LoginRequest" + } + ], + "modelEvidence": [ + { + "sourceFile": "backend/src/auth.rs", + "startLine": 54, + "endLine": 56, + "sourceSha256": "a333d129da6264b0c84443151fff194aaf69b1c4d1889f6044f3acb8aa0e3536", + "symbol": "LoginRequest", + "fields": [ + "password" + ] + } + ] + }, + "responseEvidence": { + "kind": "api-response", + "sourceFile": "backend/src/auth.rs", + "startLine": 429, + "endLine": 489, + "sourceSha256": "defd9444410e5b124ab0d621e7faf70bae335363e7e287738273e9e06cc151a1", + "symbols": [ + { + "role": "handler", + "symbol": "login" + }, + { + "role": "response-payload", + "symbol": "Value" + } + ], + "modelEvidence": [] + } + }, + "POST /api/auth/logout": { + "requestEvidence": { + "kind": "none", + "sourceFile": "backend/src/auth.rs", + "startLine": 642, + "endLine": 655, + "sourceSha256": "1ccf5c295251b2a22462cc81c3759330b65979cef524facbf2998b712c5daee1", + "symbols": [ + { + "role": "handler", + "symbol": "logout" + } + ], + "modelEvidence": [] + }, + "responseEvidence": { + "kind": "dynamic-json", + "sourceFile": "backend/src/auth.rs", + "startLine": 642, + "endLine": 655, + "sourceSha256": "1ccf5c295251b2a22462cc81c3759330b65979cef524facbf2998b712c5daee1", + "symbols": [ + { + "role": "handler", + "symbol": "logout" + } + ], + "modelEvidence": [] + } + } +} as const; diff --git a/packages/operation-registry/src/upstream-58e2204.ts b/packages/operation-registry/src/upstream-58e2204.ts new file mode 100644 index 0000000..cd49c90 --- /dev/null +++ b/packages/operation-registry/src/upstream-58e2204.ts @@ -0,0 +1,3497 @@ +import { structuredEvidence58e2204 } from './structured-evidence-58e2204.ts'; +import { sensitiveModelEvidence58e2204 } from './sensitive-evidence-58e2204.ts'; + +/** Frozen static-contract OperationRegistry audited at upstream commit 58e2204. */ +const rawUpstream58e2204Operations = [ + { + "operationId": "getDevice", + "method": "GET", + "pathTemplate": "/api/device", + "handler": "get_device_info", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1076-1090", + "responseSchemaEvidence": "ApiResponse success payload/error envelope; success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1076-1090", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "overview", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:506-506", + "backend/src/handlers.rs:1076-1090", + "bruno-api/get_device_info.bru:8" + ], + "upstreamDomain": "device-system", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getSim", + "method": "GET", + "pathTemplate": "/api/sim", + "handler": "get_sim_info", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1126-1145", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1126-1145", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "overview", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:508-508", + "backend/src/handlers.rs:1126-1145", + "bruno-api/get_sim_info.bru:8" + ], + "upstreamDomain": "sim", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postSimDetailsRefresh", + "method": "POST", + "pathTemplate": "/api/sim/details/refresh", + "handler": "refresh_sim_details_handler", + "capability": "command", + "riskLevel": "R1", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1148-1164", + "responseSchemaEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:1148-1164; field shape is implementation evidence only", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "non-idempotent", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "overview", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:510-511", + "backend/src/handlers.rs:1148-1164" + ], + "upstreamDomain": "sim", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postSimCache", + "method": "POST", + "pathTemplate": "/api/sim/cache", + "handler": "update_sim_cache_handler", + "capability": "command", + "riskLevel": "R1", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor UpdateSimCacheRequest fields [phone_number:Option, sms_center:Option] (models.rs:1036); exact handler backend/src/handlers.rs:1167-1213", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1167-1213", + "requestContentType": "application/json", + "timeoutMs": 10000, + "idempotency": "conditional", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "overview", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:514-515", + "backend/src/handlers.rs:1167-1213" + ], + "upstreamDomain": "sim", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getNetwork", + "method": "GET", + "pathTemplate": "/api/network", + "handler": "get_network_info", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1218-1232", + "responseSchemaEvidence": "ApiResponse success payload/error envelope; success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1218-1232", + "requestContentType": "none", + "timeoutMs": 30000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "cellular", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:519-520", + "backend/src/handlers.rs:1218-1232", + "bruno-api/get_network_info.bru:8" + ], + "upstreamDomain": "cellular", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getCells", + "method": "GET", + "pathTemplate": "/api/cells", + "handler": "get_cells", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1235-1249", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1235-1249", + "requestContentType": "none", + "timeoutMs": 30000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "cellular", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:522-522", + "backend/src/handlers.rs:1235-1249", + "bruno-api/get_cells_info.bru:8" + ], + "upstreamDomain": "cellular", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postCellMonitorStart", + "method": "POST", + "pathTemplate": "/api/cell-monitor/start", + "handler": "start_cell_monitor_handler", + "capability": "command", + "riskLevel": "R1", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1252-1282", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1252-1282", + "requestContentType": "none", + "timeoutMs": 30000, + "idempotency": "conditional", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "cellular", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:524-525", + "backend/src/handlers.rs:1252-1282" + ], + "upstreamDomain": "cellular", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postCellMonitorStop", + "method": "POST", + "pathTemplate": "/api/cell-monitor/stop", + "handler": "stop_cell_monitor_handler", + "capability": "command", + "riskLevel": "R1", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1285-1312", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1285-1312", + "requestContentType": "none", + "timeoutMs": 30000, + "idempotency": "conditional", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "cellular", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:528-529", + "backend/src/handlers.rs:1285-1312" + ], + "upstreamDomain": "cellular", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getRadioMode", + "method": "GET", + "pathTemplate": "/api/radio-mode", + "handler": "get_radio_mode_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1315-1329", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1315-1329", + "requestContentType": "none", + "timeoutMs": 30000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "cellular", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:532-533", + "backend/src/handlers.rs:1315-1329", + "bruno-api/get_radio_mode.bru:8" + ], + "upstreamDomain": "radio-lock", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postRadioMode", + "method": "POST", + "pathTemplate": "/api/radio-mode", + "handler": "set_radio_mode_handler", + "capability": "command", + "riskLevel": "R1", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor RadioModeRequest fields [mode:RadioMode] (models.rs:351); exact handler backend/src/handlers.rs:1332-1352", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1332-1352", + "requestContentType": "application/json", + "timeoutMs": 30000, + "idempotency": "conditional", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "cellular", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:532-534", + "backend/src/handlers.rs:1332-1352", + "bruno-api/set_radio_mode_nr.bru:8", + "bruno-api/set_radio_mode_lte.bru:8", + "bruno-api/set_radio_mode_auto.bru:8" + ], + "upstreamDomain": "radio-lock", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getBandLock", + "method": "GET", + "pathTemplate": "/api/band-lock", + "handler": "get_band_lock_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1355-1369", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1355-1369", + "requestContentType": "none", + "timeoutMs": 30000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "cellular", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:538-539", + "backend/src/handlers.rs:1355-1369", + "bruno-api/get_band_lock.bru:8" + ], + "upstreamDomain": "radio-lock", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postBandLock", + "method": "POST", + "pathTemplate": "/api/band-lock", + "handler": "set_band_lock_handler", + "capability": "job", + "riskLevel": "R2", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor BandLockRequest fields [lte_fdd_bands:Vec, lte_tdd_bands:Vec, nr_fdd_bands:Vec, nr_tdd_bands:Vec] (models.rs:377); exact handler backend/src/handlers.rs:1372-1392", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1372-1392", + "requestContentType": "application/json", + "timeoutMs": 30000, + "idempotency": "conditional", + "confirmationPolicy": "explicit", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "cellular", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:538-540", + "backend/src/handlers.rs:1372-1392", + "bruno-api/set_band_lock_nr_n78.bru:8", + "bruno-api/set_band_lock_lte_b1_b3.bru:8", + "bruno-api/unlock_all_bands.bru:8", + "bruno-api/set_band_lock_lte_nr_mix.bru:8" + ], + "upstreamDomain": "radio-lock", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getNetworkInterfaces", + "method": "GET", + "pathTemplate": "/api/network/interfaces", + "handler": "get_network_interfaces_info", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1619-1644", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1619-1644", + "requestContentType": "none", + "timeoutMs": 30000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "device-network", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:544-545", + "backend/src/handlers.rs:1619-1644", + "bruno-api/get_network_interfaces.bru:8" + ], + "upstreamDomain": "device-network", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getNetworkConnectionAddresses", + "method": "GET", + "pathTemplate": "/api/network/connection-addresses", + "handler": "get_network_connection_addresses", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1647-1666", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1647-1666", + "requestContentType": "none", + "timeoutMs": 30000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "device-network", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:548-549", + "backend/src/handlers.rs:1647-1666" + ], + "upstreamDomain": "device-network", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getDeviceNetworkDdnsConfig", + "method": "GET", + "pathTemplate": "/api/device-network/ddns/config", + "handler": "get_device_ddns_config_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1669-1679", + "responseSchemaEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:1669-1679; field shape is implementation evidence only", + "requestContentType": "none", + "timeoutMs": 30000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [ + "$.response.data.access_id", + "$.response.data.access_secret" + ], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "device-network", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:552-553", + "backend/src/handlers.rs:1669-1679", + "bruno-api/get_device_ddns_config.bru:8" + ], + "upstreamDomain": "device-network", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postDeviceNetworkDdnsConfig", + "method": "POST", + "pathTemplate": "/api/device-network/ddns/config", + "handler": "set_device_ddns_config_handler", + "capability": "job", + "riskLevel": "R2", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor crate::config::DdnsConfig fields [enabled:bool, provider:String, access_id:String, access_secret:String, interval_seconds:u64, ttl:u32, ipv4:DdnsIpConfig, ipv6:DdnsIpConfig] (config.rs:634); exact handler backend/src/handlers.rs:1682-1721", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1682-1721", + "requestContentType": "application/json", + "timeoutMs": 30000, + "idempotency": "conditional", + "confirmationPolicy": "explicit", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [ + "$.body.access_id", + "$.body.access_secret" + ], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "device-network", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:552-554", + "backend/src/handlers.rs:1682-1721", + "bruno-api/set_device_ddns_config.bru:8" + ], + "upstreamDomain": "device-network", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getDeviceNetworkDdnsStatus", + "method": "GET", + "pathTemplate": "/api/device-network/ddns/status", + "handler": "get_device_ddns_status_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1750-1757", + "responseSchemaEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:1750-1757; field shape is implementation evidence only", + "requestContentType": "none", + "timeoutMs": 30000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "device-network", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:558-559", + "backend/src/handlers.rs:1750-1757", + "bruno-api/get_device_ddns_status.bru:8" + ], + "upstreamDomain": "device-network", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postDeviceNetworkDdnsSync", + "method": "POST", + "pathTemplate": "/api/device-network/ddns/sync", + "handler": "sync_device_ddns_handler", + "capability": "command", + "riskLevel": "R1", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1760-1781", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1760-1781", + "requestContentType": "none", + "timeoutMs": 30000, + "idempotency": "conditional", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "device-network", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:562-563", + "backend/src/handlers.rs:1760-1781", + "bruno-api/post_device_ddns_sync.bru:8" + ], + "upstreamDomain": "device-network", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getDeviceNetworkDdnsLogs", + "method": "GET", + "pathTemplate": "/api/device-network/ddns/logs", + "handler": "get_device_ddns_logs_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1784-1790", + "responseSchemaEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:1784-1790; field shape is implementation evidence only", + "requestContentType": "none", + "timeoutMs": 30000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "device-network", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:566-567", + "backend/src/handlers.rs:1784-1790", + "bruno-api/get_device_ddns_logs.bru:8" + ], + "upstreamDomain": "device-network", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postDeviceNetworkDdnsLogsClear", + "method": "POST", + "pathTemplate": "/api/device-network/ddns/logs/clear", + "handler": "clear_device_ddns_logs_handler", + "capability": "job", + "riskLevel": "R2", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1793-1802", + "responseSchemaEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:1793-1802; field shape is implementation evidence only", + "requestContentType": "none", + "timeoutMs": 30000, + "idempotency": "conditional", + "confirmationPolicy": "explicit", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "device-network", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:570-571", + "backend/src/handlers.rs:1793-1802", + "bruno-api/post_device_ddns_logs_clear.bru:8" + ], + "upstreamDomain": "device-network", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getDeviceNetworkWlanStatus", + "method": "GET", + "pathTemplate": "/api/device-network/wlan/status", + "handler": "get_device_wlan_status_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1805-1819", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1805-1819", + "requestContentType": "none", + "timeoutMs": 30000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [ + "$.response.data.ssid" + ], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "device-network", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:574-575", + "backend/src/handlers.rs:1805-1819", + "bruno-api/get_device_wlan_status.bru:8" + ], + "upstreamDomain": "device-network", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postDeviceNetworkWlanEnabled", + "method": "POST", + "pathTemplate": "/api/device-network/wlan/enabled", + "handler": "set_device_wlan_enabled_handler", + "capability": "command", + "riskLevel": "R1", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor WlanEnabledRequest fields [enabled:bool] (models.rs:606); exact handler backend/src/handlers.rs:1822-1841", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1822-1841", + "requestContentType": "application/json", + "timeoutMs": 30000, + "idempotency": "conditional", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "device-network", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:578-579", + "backend/src/handlers.rs:1822-1841", + "bruno-api/set_device_wlan_enabled.bru:8" + ], + "upstreamDomain": "device-network", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postDeviceNetworkWlanScan", + "method": "POST", + "pathTemplate": "/api/device-network/wlan/scan", + "handler": "scan_device_wlan_handler", + "capability": "command", + "riskLevel": "R1", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1844-1858", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1844-1858", + "requestContentType": "none", + "timeoutMs": 30000, + "idempotency": "non-idempotent", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "device-network", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:582-583", + "backend/src/handlers.rs:1844-1858", + "bruno-api/post_device_wlan_scan.bru:8" + ], + "upstreamDomain": "device-network", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getDeviceNetworkWlanProfiles", + "method": "GET", + "pathTemplate": "/api/device-network/wlan/profiles", + "handler": "get_device_wlan_profiles_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1861-1875", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1861-1875", + "requestContentType": "none", + "timeoutMs": 30000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [ + "$.response.data[*].ssid" + ], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "device-network", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:586-587", + "backend/src/handlers.rs:1861-1875", + "bruno-api/get_device_wlan_profiles.bru:8" + ], + "upstreamDomain": "device-network", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postDeviceNetworkWlanForget", + "method": "POST", + "pathTemplate": "/api/device-network/wlan/forget", + "handler": "forget_device_wlan_handler", + "capability": "job", + "riskLevel": "R2", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor WlanForgetRequest fields [uuid:String, connection_id:String] (models.rs:635); exact handler backend/src/handlers.rs:1878-1897", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1878-1897", + "requestContentType": "application/json", + "timeoutMs": 30000, + "idempotency": "conditional", + "confirmationPolicy": "explicit", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "device-network", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:590-591", + "backend/src/handlers.rs:1878-1897", + "bruno-api/post_device_wlan_forget.bru:8" + ], + "upstreamDomain": "device-network", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postDeviceNetworkWlanConnect", + "method": "POST", + "pathTemplate": "/api/device-network/wlan/connect", + "handler": "connect_device_wlan_handler", + "capability": "command", + "riskLevel": "R1", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor WlanConnectRequest fields [ssid:String, password:String, auto_join:bool] (models.rs:611); exact handler backend/src/handlers.rs:1900-1955", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1900-1955", + "requestContentType": "application/json", + "timeoutMs": 30000, + "idempotency": "non-idempotent", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [ + "$.body.ssid", + "$.body.password" + ], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "device-network", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:594-595", + "backend/src/handlers.rs:1900-1955", + "bruno-api/post_device_wlan_connect.bru:8" + ], + "upstreamDomain": "device-network", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postDeviceNetworkWlanDisconnect", + "method": "POST", + "pathTemplate": "/api/device-network/wlan/disconnect", + "handler": "disconnect_device_wlan_handler", + "capability": "command", + "riskLevel": "R1", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1958-1991", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1958-1991", + "requestContentType": "none", + "timeoutMs": 30000, + "idempotency": "non-idempotent", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "device-network", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:598-599", + "backend/src/handlers.rs:1958-1991", + "bruno-api/post_device_wlan_disconnect.bru:8" + ], + "upstreamDomain": "device-network", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postDeviceNetworkWlanProfile", + "method": "POST", + "pathTemplate": "/api/device-network/wlan/profile", + "handler": "save_device_wlan_profile_handler", + "capability": "command", + "riskLevel": "R1", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor WlanProfileRequest fields [connection_id:String, auto_join:Option, ipv4_mode:Option, ipv4_address:Option, ipv4_prefix:Option, ipv4_gateway:Option] (models.rs:620); exact handler backend/src/handlers.rs:1994-2013", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1994-2013", + "requestContentType": "application/json", + "timeoutMs": 30000, + "idempotency": "conditional", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [ + "$.body.credentials" + ], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "device-network", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:602-603", + "backend/src/handlers.rs:1994-2013", + "bruno-api/post_device_wlan_profile.bru:8" + ], + "upstreamDomain": "device-network", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getNetworkSignalStrength", + "method": "GET", + "pathTemplate": "/api/network/signal-strength", + "handler": "get_signal_strength_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:2016-2030", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2016-2030", + "requestContentType": "none", + "timeoutMs": 30000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "cellular", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:606-607", + "backend/src/handlers.rs:2016-2030", + "bruno-api/get_signal_strength.bru:8" + ], + "upstreamDomain": "cellular", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getLocationCellInfo", + "method": "GET", + "pathTemplate": "/api/location/cell-info", + "handler": "get_cell_location_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1395-1409", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1395-1409", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "cellular", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:610-611", + "backend/src/handlers.rs:1395-1409", + "bruno-api/get_cell_location_info.bru:8" + ], + "upstreamDomain": "cellular", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getNetworkOperators", + "method": "GET", + "pathTemplate": "/api/network/operators", + "handler": "get_network_operators", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1412-1426", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1412-1426", + "requestContentType": "none", + "timeoutMs": 30000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "cellular", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:614-615", + "backend/src/handlers.rs:1412-1426", + "bruno-api/get_operators.bru:8" + ], + "upstreamDomain": "cellular", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getNetworkOperatorsScan", + "method": "GET", + "pathTemplate": "/api/network/operators/scan", + "handler": "scan_network_operators", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1429-1443", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1429-1443", + "requestContentType": "none", + "timeoutMs": 30000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "cellular", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:618-619", + "backend/src/handlers.rs:1429-1443", + "bruno-api/scan_operators.bru:8" + ], + "upstreamDomain": "cellular", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postNetworkRegisterManual", + "method": "POST", + "pathTemplate": "/api/network/register-manual", + "handler": "register_network_manual", + "capability": "job", + "riskLevel": "R2", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor ManualRegisterRequest fields [mccmnc:String] (models.rs:771); exact handler backend/src/handlers.rs:1446-1466", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1446-1466", + "requestContentType": "application/json", + "timeoutMs": 30000, + "idempotency": "non-idempotent", + "confirmationPolicy": "explicit", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "cellular", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:622-623", + "backend/src/handlers.rs:1446-1466", + "bruno-api/register_operator_manual.bru:8" + ], + "upstreamDomain": "cellular", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postNetworkRegisterAuto", + "method": "POST", + "pathTemplate": "/api/network/register-auto", + "handler": "register_network_auto", + "capability": "job", + "riskLevel": "R2", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1469-1486", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1469-1486", + "requestContentType": "none", + "timeoutMs": 30000, + "idempotency": "non-idempotent", + "confirmationPolicy": "explicit", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "cellular", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:626-627", + "backend/src/handlers.rs:1469-1486", + "bruno-api/register_operator_auto.bru:8" + ], + "upstreamDomain": "cellular", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getApn", + "method": "GET", + "pathTemplate": "/api/apn", + "handler": "get_apn_list_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1489-1504", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1489-1504", + "requestContentType": "none", + "timeoutMs": 30000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [ + "$.body.username", + "$.body.password" + ], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "cellular", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:630-631", + "backend/src/handlers.rs:1489-1504", + "bruno-api/get_apn_list.bru:8" + ], + "upstreamDomain": "data-connection", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postApn", + "method": "POST", + "pathTemplate": "/api/apn", + "handler": "set_apn_handler", + "capability": "command", + "riskLevel": "R1", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor SetApnRequest fields [context_path:String, apn:Option, protocol:Option, username:Option, password:Option, auth_method:Option] (models.rs:795); exact handler backend/src/handlers.rs:1507-1571", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1507-1571", + "requestContentType": "application/json", + "timeoutMs": 30000, + "idempotency": "conditional", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [ + "$.body.username", + "$.body.password" + ], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "cellular", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:630-632", + "backend/src/handlers.rs:1507-1571", + "bruno-api/set_apn.bru:8" + ], + "upstreamDomain": "data-connection", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getCellLock", + "method": "GET", + "pathTemplate": "/api/cell-lock", + "handler": "get_cell_lock_status_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1574-1582", + "responseSchemaEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:1574-1582; field shape is implementation evidence only", + "requestContentType": "none", + "timeoutMs": 30000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "cellular", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:636-637", + "backend/src/handlers.rs:1574-1582", + "bruno-api/get_cell_lock.bru:8" + ], + "upstreamDomain": "radio-lock", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postCellLock", + "method": "POST", + "pathTemplate": "/api/cell-lock", + "handler": "set_cell_lock_handler", + "capability": "job", + "riskLevel": "R2", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor CellLockRequest fields [rat:u8, enable:bool, lock_type:u8, pci:Option, arfcn:Option] (models.rs:405); exact handler backend/src/handlers.rs:1585-1603", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1585-1603", + "requestContentType": "application/json", + "timeoutMs": 30000, + "idempotency": "conditional", + "confirmationPolicy": "explicit", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "cellular", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:636-638", + "backend/src/handlers.rs:1585-1603", + "bruno-api/set_cell_lock_lte.bru:8", + "bruno-api/set_cell_lock_nr.bru:8", + "bruno-api/unlock_cell_nr.bru:8" + ], + "upstreamDomain": "radio-lock", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postCellLockUnlockAll", + "method": "POST", + "pathTemplate": "/api/cell-lock/unlock-all", + "handler": "unlock_all_cells_handler", + "capability": "job", + "riskLevel": "R2", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1606-1616", + "responseSchemaEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:1606-1616; field shape is implementation evidence only", + "requestContentType": "none", + "timeoutMs": 30000, + "idempotency": "conditional", + "confirmationPolicy": "explicit", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "cellular", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:642-643", + "backend/src/handlers.rs:1606-1616", + "bruno-api/unlock_all_cells.bru:8" + ], + "upstreamDomain": "radio-lock", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getData", + "method": "GET", + "pathTemplate": "/api/data", + "handler": "get_data_status", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:2035-2062", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2035-2062", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "cellular", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:647-648", + "backend/src/handlers.rs:2035-2062", + "bruno-api/get_data_status.bru:8" + ], + "upstreamDomain": "data-connection", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postData", + "method": "POST", + "pathTemplate": "/api/data", + "handler": "set_data_status", + "capability": "command", + "riskLevel": "R1", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor DataConnectionRequest fields [active:bool] (models.rs:234); exact handler backend/src/handlers.rs:2065-2131", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2065-2131", + "requestContentType": "application/json", + "timeoutMs": 10000, + "idempotency": "conditional", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "cellular", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:647-649", + "backend/src/handlers.rs:2065-2131", + "bruno-api/set_data_status_disable.bru:8", + "bruno-api/set_data_status_enable.bru:8" + ], + "upstreamDomain": "data-connection", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getRoaming", + "method": "GET", + "pathTemplate": "/api/roaming", + "handler": "get_roaming_status_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:2172-2193", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2172-2193", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "cellular", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:653-654", + "backend/src/handlers.rs:2172-2193", + "bruno-api/get_roaming_status.bru:8" + ], + "upstreamDomain": "data-connection", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postRoaming", + "method": "POST", + "pathTemplate": "/api/roaming", + "handler": "set_roaming_status_handler", + "capability": "command", + "riskLevel": "R1", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor RoamingRequest fields [allowed:bool] (models.rs:244); exact handler backend/src/handlers.rs:2196-2247", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2196-2247", + "requestContentType": "application/json", + "timeoutMs": 10000, + "idempotency": "conditional", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "cellular", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:653-655", + "backend/src/handlers.rs:2196-2247", + "bruno-api/set_roaming_enable.bru:8", + "bruno-api/set_roaming_disable.bru:8" + ], + "upstreamDomain": "data-connection", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getAirplaneMode", + "method": "GET", + "pathTemplate": "/api/airplane-mode", + "handler": "get_airplane_mode_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:2315-2329", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2315-2329", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "cellular", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:659-660", + "backend/src/handlers.rs:2315-2329", + "bruno-api/get_airplane_mode.bru:8" + ], + "upstreamDomain": "data-connection", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postAirplaneMode", + "method": "POST", + "pathTemplate": "/api/airplane-mode", + "handler": "set_airplane_mode_handler", + "capability": "command", + "riskLevel": "R1", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor AirplaneModeRequest fields [enabled:bool] (models.rs:255); exact handler backend/src/handlers.rs:2250-2312", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2250-2312", + "requestContentType": "application/json", + "timeoutMs": 10000, + "idempotency": "conditional", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "cellular", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:659-661", + "backend/src/handlers.rs:2250-2312", + "bruno-api/set_airplane_mode_disable.bru:8", + "bruno-api/set_airplane_mode_enable.bru:8" + ], + "upstreamDomain": "data-connection", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postBasebandRestart", + "method": "POST", + "pathTemplate": "/api/baseband/restart", + "handler": "restart_baseband_handler", + "capability": "job", + "riskLevel": "R3", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:2133-2159", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2133-2159", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "non-idempotent", + "confirmationPolicy": "strong", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "cellular", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:665-666", + "backend/src/handlers.rs:2133-2159" + ], + "upstreamDomain": "data-connection", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getBasebandRestartStatus", + "method": "GET", + "pathTemplate": "/api/baseband/restart/status", + "handler": "get_baseband_restart_status_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:2161-2169", + "responseSchemaEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:2161-2169; field shape is implementation evidence only", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "cellular", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:669-670", + "backend/src/handlers.rs:2161-2169" + ], + "upstreamDomain": "data-connection", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getWorkMode", + "method": "GET", + "pathTemplate": "/api/work-mode", + "handler": "get_work_mode_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:352-365", + "responseSchemaEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:352-365; field shape is implementation evidence only", + "requestContentType": "none", + "timeoutMs": 30000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "esim", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:674-675", + "backend/src/handlers.rs:352-365" + ], + "upstreamDomain": "workmode-esim", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postWorkMode", + "method": "POST", + "pathTemplate": "/api/work-mode", + "handler": "set_work_mode_handler", + "capability": "job", + "riskLevel": "R2", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor WorkModeRequest fields [mode:WorkMode, confirm:bool] (models.rs:57); exact handler backend/src/handlers.rs:368-405", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:368-405", + "requestContentType": "application/json", + "timeoutMs": 30000, + "idempotency": "conditional", + "confirmationPolicy": "explicit", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "esim", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:674-676", + "backend/src/handlers.rs:368-405" + ], + "upstreamDomain": "workmode-esim", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getEsimConfig", + "method": "GET", + "pathTemplate": "/api/esim/config", + "handler": "get_esim_config_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:456-462", + "responseSchemaEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:456-462; field shape is implementation evidence only", + "requestContentType": "none", + "timeoutMs": 30000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "esim", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:680-681", + "backend/src/handlers.rs:456-462" + ], + "upstreamDomain": "workmode-esim", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postEsimConfig", + "method": "POST", + "pathTemplate": "/api/esim/config", + "handler": "set_esim_config_handler", + "capability": "job", + "riskLevel": "R2", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor crate::config::EsimConfig fields [lpac_path:String, custom_memory_total_kb:Option] (config.rs:1660); exact handler backend/src/handlers.rs:465-482", + "responseSchemaEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:465-482; field shape is implementation evidence only", + "requestContentType": "application/json", + "timeoutMs": 30000, + "idempotency": "conditional", + "confirmationPolicy": "explicit", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "esim", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:680-682", + "backend/src/handlers.rs:465-482" + ], + "upstreamDomain": "workmode-esim", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getEsimLpacStatus", + "method": "GET", + "pathTemplate": "/api/esim/lpac/status", + "handler": "get_esim_lpac_status_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:408-416", + "responseSchemaEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:408-416; field shape is implementation evidence only", + "requestContentType": "none", + "timeoutMs": 30000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "esim", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:686-687", + "backend/src/handlers.rs:408-416" + ], + "upstreamDomain": "workmode-esim", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postEsimLpacRepair", + "method": "POST", + "pathTemplate": "/api/esim/lpac/repair", + "handler": "repair_esim_lpac_handler", + "capability": "job", + "riskLevel": "R2", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor EsimLpacRepairRequest fields [proxy_prefix:Option, asset_url:Option] (models.rs:153); exact handler backend/src/handlers.rs:419-453", + "responseSchemaEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:419-453; field shape is implementation evidence only", + "requestContentType": "application/json", + "timeoutMs": 30000, + "idempotency": "conditional", + "confirmationPolicy": "explicit", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "esim", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:690-691", + "backend/src/handlers.rs:419-453" + ], + "upstreamDomain": "workmode-esim", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getEsimEuicc", + "method": "GET", + "pathTemplate": "/api/esim/euicc", + "handler": "get_esim_euicc_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "query extractor std::collections::HashMap; exact exact handler backend/src/handlers.rs:485-521", + "responseSchemaEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:485-521; field shape is implementation evidence only", + "requestContentType": "none", + "timeoutMs": 30000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "esim", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:694-695", + "backend/src/handlers.rs:485-521" + ], + "upstreamDomain": "workmode-esim", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getEsimProfiles", + "method": "GET", + "pathTemplate": "/api/esim/profiles", + "handler": "get_esim_profiles_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "query extractor std::collections::HashMap; exact exact handler backend/src/handlers.rs:524-598", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:524-598", + "requestContentType": "none", + "timeoutMs": 30000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "esim", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:698-699", + "backend/src/handlers.rs:524-598" + ], + "upstreamDomain": "workmode-esim", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postEsimProfiles", + "method": "POST", + "pathTemplate": "/api/esim/profiles", + "handler": "download_esim_profile_handler", + "capability": "command", + "riskLevel": "R1", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor EsimDownloadRequest fields [smdp:String, matching_id:String, confirmation_code:Option, imei:Option] (models.rs:174); exact handler backend/src/handlers.rs:803-1071", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:803-1071", + "requestContentType": "application/json", + "timeoutMs": 30000, + "idempotency": "conditional", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [ + "$.body.matching_id", + "$.body.confirmation_code", + "$.body.imei" + ], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "esim", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:698-700", + "backend/src/handlers.rs:803-1071" + ], + "upstreamDomain": "workmode-esim", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postEsimProfilesIccidEnable", + "method": "POST", + "pathTemplate": "/api/esim/profiles/{iccid}/enable", + "handler": "enable_esim_profile_handler", + "capability": "command", + "riskLevel": "R1", + "sessionSensitive": false, + "requestSchemaEvidence": "path extractor String; exact handler backend/src/handlers.rs:601-726", + "responseSchemaEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:601-726; field shape is implementation evidence only", + "requestContentType": "none", + "timeoutMs": 30000, + "idempotency": "conditional", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "esim", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:704-705", + "backend/src/handlers.rs:601-726" + ], + "upstreamDomain": "workmode-esim", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postEsimProfilesIccidRename", + "method": "POST", + "pathTemplate": "/api/esim/profiles/{iccid}/rename", + "handler": "rename_esim_profile_handler", + "capability": "command", + "riskLevel": "R1", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor EsimRenameRequest fields [name:String] (models.rs:169); path extractor String; exact handler backend/src/handlers.rs:729-750", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:729-750", + "requestContentType": "application/json", + "timeoutMs": 30000, + "idempotency": "conditional", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "esim", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:708-709", + "backend/src/handlers.rs:729-750" + ], + "upstreamDomain": "workmode-esim", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "deleteEsimProfilesIccid", + "method": "DELETE", + "pathTemplate": "/api/esim/profiles/{iccid}", + "handler": "delete_esim_profile_handler", + "capability": "job", + "riskLevel": "R3", + "sessionSensitive": false, + "requestSchemaEvidence": "path extractor String; exact handler backend/src/handlers.rs:753-780", + "responseSchemaEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:753-780; field shape is implementation evidence only", + "requestContentType": "none", + "timeoutMs": 30000, + "idempotency": "idempotent", + "confirmationPolicy": "strong", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "esim", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:712-713", + "backend/src/handlers.rs:753-780" + ], + "upstreamDomain": "workmode-esim", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getCalls", + "method": "GET", + "pathTemplate": "/api/calls", + "handler": "get_calls_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:2634-2655", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2634-2655", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "calls", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:717-718", + "backend/src/handlers.rs:2634-2655", + "bruno-api/get_calls.bru:8" + ], + "upstreamDomain": "calls", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postCallDial", + "method": "POST", + "pathTemplate": "/api/call/dial", + "handler": "dial_call_handler", + "capability": "command", + "riskLevel": "R1", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor MakeCallRequest fields [phone_number:String] (models.rs:810); exact handler backend/src/handlers.rs:2657-2689", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2657-2689", + "requestContentType": "application/json", + "timeoutMs": 10000, + "idempotency": "non-idempotent", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [ + "$.body.phone_number" + ], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "calls", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:721-722", + "backend/src/handlers.rs:2657-2689", + "bruno-api/post_call_dial.bru:8" + ], + "upstreamDomain": "calls", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postCallHangup", + "method": "POST", + "pathTemplate": "/api/call/hangup", + "handler": "hangup_call_handler", + "capability": "command", + "riskLevel": "R1", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor HangupCallRequest fields [path:String] (models.rs:830); exact handler backend/src/handlers.rs:2691-2725", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2691-2725", + "requestContentType": "application/json", + "timeoutMs": 10000, + "idempotency": "non-idempotent", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "calls", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:725-726", + "backend/src/handlers.rs:2691-2725", + "bruno-api/post_call_hangup.bru:8" + ], + "upstreamDomain": "calls", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postCallHangupAll", + "method": "POST", + "pathTemplate": "/api/call/hangup-all", + "handler": "hangup_all_calls_handler", + "capability": "command", + "riskLevel": "R1", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:2727-2760", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2727-2760", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "non-idempotent", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "calls", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:729-730", + "backend/src/handlers.rs:2727-2760", + "bruno-api/post_call_hangup_all.bru:8" + ], + "upstreamDomain": "calls", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postCallAnswer", + "method": "POST", + "pathTemplate": "/api/call/answer", + "handler": "answer_call_handler", + "capability": "command", + "riskLevel": "R1", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor HangupCallRequest fields [path:String] (models.rs:830); exact handler backend/src/handlers.rs:2762-2789", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2762-2789", + "requestContentType": "application/json", + "timeoutMs": 10000, + "idempotency": "non-idempotent", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "calls", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:733-734", + "backend/src/handlers.rs:2762-2789", + "bruno-api/post_call_answer.bru:8" + ], + "upstreamDomain": "calls", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getCallVolume", + "method": "GET", + "pathTemplate": "/api/call/volume", + "handler": "get_call_volume_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:2899-2906", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2899-2906", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "calls", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:737-738", + "backend/src/handlers.rs:2899-2906", + "bruno-api/get_call_volume.bru:8" + ], + "upstreamDomain": "calls", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postCallVolume", + "method": "POST", + "pathTemplate": "/api/call/volume", + "handler": "set_call_volume_handler", + "capability": "command", + "riskLevel": "R1", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor SetCallVolumeRequest fields [speaker_volume:Option, microphone_volume:Option, muted:Option] (models.rs:856); exact handler backend/src/handlers.rs:2908-2922", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2908-2922", + "requestContentType": "application/json", + "timeoutMs": 10000, + "idempotency": "conditional", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "calls", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:737-739", + "backend/src/handlers.rs:2908-2922", + "bruno-api/set_call_volume.bru:8" + ], + "upstreamDomain": "calls", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getCallForwarding", + "method": "GET", + "pathTemplate": "/api/call/forwarding", + "handler": "get_call_forwarding_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:2924-2931", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2924-2931", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "calls", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:743-744", + "backend/src/handlers.rs:2924-2931", + "bruno-api/get_call_forwarding.bru:8" + ], + "upstreamDomain": "calls", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postCallForwarding", + "method": "POST", + "pathTemplate": "/api/call/forwarding", + "handler": "set_call_forwarding_handler", + "capability": "command", + "riskLevel": "R1", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor SetCallForwardingRequest fields [forward_type:String, number:String, timeout:Option] (models.rs:891); exact handler backend/src/handlers.rs:2933-2943", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2933-2943", + "requestContentType": "application/json", + "timeoutMs": 10000, + "idempotency": "conditional", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [ + "$.body.number" + ], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "calls", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:743-745", + "backend/src/handlers.rs:2933-2943", + "bruno-api/set_call_forwarding.bru:8" + ], + "upstreamDomain": "calls", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getCallSettings", + "method": "GET", + "pathTemplate": "/api/call/settings", + "handler": "get_call_settings_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:2852-2866", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2852-2866", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "calls", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:749-750", + "backend/src/handlers.rs:2852-2866", + "bruno-api/get_call_settings.bru:8" + ], + "upstreamDomain": "calls", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postCallSettings", + "method": "POST", + "pathTemplate": "/api/call/settings", + "handler": "set_call_settings_handler", + "capability": "job", + "riskLevel": "R2", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor SetCallSettingRequest fields [property:String, value:String] (models.rs:875); exact handler backend/src/handlers.rs:2868-2897", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2868-2897", + "requestContentType": "application/json", + "timeoutMs": 10000, + "idempotency": "conditional", + "confirmationPolicy": "explicit", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "calls", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:749-751", + "backend/src/handlers.rs:2868-2897", + "bruno-api/set_call_settings.bru:8" + ], + "upstreamDomain": "calls", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getCallHistory", + "method": "GET", + "pathTemplate": "/api/call/history", + "handler": "get_call_history_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "query extractor CallHistoryRequest fields [limit:i64, offset:i64] (models.rs:835); exact handler backend/src/handlers.rs:2791-2813", + "responseSchemaEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2791-2813", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "calls", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:755-756", + "backend/src/handlers.rs:2791-2813" + ], + "upstreamDomain": "calls", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "deleteCallHistoryId", + "method": "DELETE", + "pathTemplate": "/api/call/history/{id}", + "handler": "delete_call_history_handler", + "capability": "job", + "riskLevel": "R3", + "sessionSensitive": false, + "requestSchemaEvidence": "path extractor i64; exact handler backend/src/handlers.rs:2815-2832", + "responseSchemaEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2815-2832", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "idempotent", + "confirmationPolicy": "strong", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "calls", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:759-760", + "backend/src/handlers.rs:2815-2832", + "bruno-api/delete_call_history.bru:8" + ], + "upstreamDomain": "calls", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postCallHistoryClear", + "method": "POST", + "pathTemplate": "/api/call/history/clear", + "handler": "clear_call_history_handler", + "capability": "job", + "riskLevel": "R3", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:2834-2850", + "responseSchemaEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2834-2850", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "conditional", + "confirmationPolicy": "strong", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "calls", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:763-764", + "backend/src/handlers.rs:2834-2850", + "bruno-api/clear_call_history.bru:8" + ], + "upstreamDomain": "calls", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getImsStatus", + "method": "GET", + "pathTemplate": "/api/ims/status", + "handler": "get_ims_status_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:2945-2952", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2945-2952", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "calls", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:767-768", + "backend/src/handlers.rs:2945-2952", + "bruno-api/get_ims_status.bru:8" + ], + "upstreamDomain": "calls", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getVoicemailStatus", + "method": "GET", + "pathTemplate": "/api/voicemail/status", + "handler": "get_voicemail_status_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:2954-2961", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2954-2961", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "calls", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:771-772", + "backend/src/handlers.rs:2954-2961", + "bruno-api/get_voicemail_status.bru:8" + ], + "upstreamDomain": "calls", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postSmsSend", + "method": "POST", + "pathTemplate": "/api/sms/send", + "handler": "send_sms_handler", + "capability": "job", + "riskLevel": "R2", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor SendSmsRequest fields [phone_number:String, content:String] (models.rs:912); exact handler backend/src/handlers.rs:2373-2404", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2373-2404", + "requestContentType": "application/json", + "timeoutMs": 10000, + "idempotency": "non-idempotent", + "confirmationPolicy": "explicit", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [ + "$.body.phone_number", + "$.body.content" + ], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "messages", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:776-777", + "backend/src/handlers.rs:2373-2404", + "bruno-api/post_sms_send.bru:8" + ], + "upstreamDomain": "messages", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getSmsList", + "method": "GET", + "pathTemplate": "/api/sms/list", + "handler": "get_sms_list_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "query extractor SmsListRequest fields [limit:i64, offset:i64, direction:Option] (models.rs:918); exact handler backend/src/handlers.rs:2407-2434", + "responseSchemaEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2407-2434", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "messages", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:780-781", + "backend/src/handlers.rs:2407-2434" + ], + "upstreamDomain": "messages", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getSmsConversation", + "method": "GET", + "pathTemplate": "/api/sms/conversation", + "handler": "get_sms_conversation_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "query extractor SmsConversationRequest fields [phone_number:String, limit:i64] (models.rs:927); exact handler backend/src/handlers.rs:2437-2458", + "responseSchemaEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2437-2458", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [ + "$.query.phone_number" + ], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "messages", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:784-785", + "backend/src/handlers.rs:2437-2458" + ], + "upstreamDomain": "messages", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getSmsStats", + "method": "GET", + "pathTemplate": "/api/sms/stats", + "handler": "get_sms_stats_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:2461-2477", + "responseSchemaEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2461-2477", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "messages", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:788-789", + "backend/src/handlers.rs:2461-2477", + "bruno-api/get_sms_stats.bru:8" + ], + "upstreamDomain": "messages", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postSmsBatchDelete", + "method": "POST", + "pathTemplate": "/api/sms/batch-delete", + "handler": "delete_sms_batch_handler", + "capability": "job", + "riskLevel": "R3", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor SmsBatchDeleteRequest fields [ids:Vec, phone_numbers:Vec] (models.rs:934); exact handler backend/src/handlers.rs:2551-2578", + "responseSchemaEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2551-2578", + "requestContentType": "application/json", + "timeoutMs": 10000, + "idempotency": "conditional", + "confirmationPolicy": "strong", + "batchable": true, + "syncSafe": false, + "sensitiveFields": [ + "$.body.phone_numbers[*]" + ], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "messages", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:792-793", + "backend/src/handlers.rs:2551-2578" + ], + "upstreamDomain": "messages", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "deleteSmsConversationPhoneNumber", + "method": "DELETE", + "pathTemplate": "/api/sms/conversation/{phone_number}", + "handler": "delete_sms_conversation_handler", + "capability": "job", + "riskLevel": "R3", + "sessionSensitive": false, + "requestSchemaEvidence": "path extractor String; exact handler backend/src/handlers.rs:2528-2548", + "responseSchemaEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2528-2548", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "idempotent", + "confirmationPolicy": "strong", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [ + "$.path.phone_number" + ], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "messages", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:796-797", + "backend/src/handlers.rs:2528-2548" + ], + "upstreamDomain": "messages", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "deleteSmsMessageId", + "method": "DELETE", + "pathTemplate": "/api/sms/message/{id}", + "handler": "delete_sms_message_handler", + "capability": "job", + "riskLevel": "R3", + "sessionSensitive": false, + "requestSchemaEvidence": "path extractor i64; exact handler backend/src/handlers.rs:2508-2525", + "responseSchemaEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2508-2525", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "idempotent", + "confirmationPolicy": "strong", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "messages", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:800-801", + "backend/src/handlers.rs:2508-2525" + ], + "upstreamDomain": "messages", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postSmsClear", + "method": "POST", + "pathTemplate": "/api/sms/clear", + "handler": "clear_sms_handler", + "capability": "job", + "riskLevel": "R3", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:2480-2505", + "responseSchemaEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2480-2505", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "conditional", + "confirmationPolicy": "strong", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "messages", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:804-805", + "backend/src/handlers.rs:2480-2505", + "bruno-api/post_sms_clear.bru:8" + ], + "upstreamDomain": "messages", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getStats", + "method": "GET", + "pathTemplate": "/api/stats", + "handler": "get_system_stats", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:3272-3288", + "responseSchemaEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:3272-3288; field shape is implementation evidence only", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "overview", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:808-808", + "backend/src/handlers.rs:3272-3288", + "bruno-api/get_stats.bru:8" + ], + "upstreamDomain": "device-system", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getStatsCpu", + "method": "GET", + "pathTemplate": "/api/stats/cpu", + "handler": "get_cpu_info", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:3291-3302", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:3291-3302", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "overview", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:809-809", + "backend/src/handlers.rs:3291-3302", + "bruno-api/get_cpu_info.bru:8" + ], + "upstreamDomain": "device-system", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getConnectivity", + "method": "GET", + "pathTemplate": "/api/connectivity", + "handler": "get_connectivity_check", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:3305-3322", + "responseSchemaEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:3305-3322", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "overview", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:811-812", + "backend/src/handlers.rs:3305-3322" + ], + "upstreamDomain": "device-system", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postSystemReboot", + "method": "POST", + "pathTemplate": "/api/system/reboot", + "handler": "system_reboot", + "capability": "job", + "riskLevel": "R3", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor SystemRebootRequest fields [delay_seconds:u32] (models.rs:422); exact handler backend/src/handlers.rs:3381-3406", + "responseSchemaEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:3381-3406; field shape is implementation evidence only", + "requestContentType": "application/json", + "timeoutMs": 10000, + "idempotency": "non-idempotent", + "confirmationPolicy": "strong", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "overview", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:815-816", + "backend/src/handlers.rs:3381-3406", + "bruno-api/post_system_reboot.bru:8" + ], + "upstreamDomain": "device-system", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postServiceRestart", + "method": "POST", + "pathTemplate": "/api/service/restart", + "handler": "restart_service_handler", + "capability": "job", + "riskLevel": "R3", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:3578-3601", + "responseSchemaEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:3578-3601; field shape is implementation evidence only", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "non-idempotent", + "confirmationPolicy": "strong", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "overview", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:819-820", + "backend/src/handlers.rs:3578-3601" + ], + "upstreamDomain": "device-system", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getNotificationsConfig", + "method": "GET", + "pathTemplate": "/api/notifications/config", + "handler": "get_notification_config_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:3641-3652", + "responseSchemaEvidence": "Rust return ( StatusCode, Json>, ); exact handler backend/src/handlers.rs:3641-3652", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [ + "$.response.data.channels[*].config" + ], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "notifications", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:824-825", + "backend/src/handlers.rs:3641-3652", + "bruno-api/get_notification_config.bru:8" + ], + "upstreamDomain": "notifications", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postNotificationsConfig", + "method": "POST", + "pathTemplate": "/api/notifications/config", + "handler": "set_notification_config_handler", + "capability": "job", + "riskLevel": "R2", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor crate::config::NotificationConfig fields [version:u8, channels:Vec, rules:Vec, log_cleanup:NotificationLogCleanupConfig] (config.rs:548); exact handler backend/src/handlers.rs:3655-3672", + "responseSchemaEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:3655-3672", + "requestContentType": "application/json", + "timeoutMs": 10000, + "idempotency": "conditional", + "confirmationPolicy": "explicit", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [ + "$.body.channels[*].config" + ], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "notifications", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:824-826", + "backend/src/handlers.rs:3655-3672", + "bruno-api/set_notification_config.bru:8" + ], + "upstreamDomain": "notifications", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postNotificationsTestChannel", + "method": "POST", + "pathTemplate": "/api/notifications/test/{channel}", + "handler": "test_notification_channel_handler", + "capability": "job", + "riskLevel": "R2", + "sessionSensitive": false, + "requestSchemaEvidence": "path extractor String; exact handler backend/src/handlers.rs:3675-3704", + "responseSchemaEvidence": "Rust return ( StatusCode, Json>, ); exact handler backend/src/handlers.rs:3675-3704", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "non-idempotent", + "confirmationPolicy": "explicit", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "notifications", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:830-831", + "backend/src/handlers.rs:3675-3704" + ], + "upstreamDomain": "notifications", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getNotificationsLogs", + "method": "GET", + "pathTemplate": "/api/notifications/logs", + "handler": "get_notification_logs_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "query extractor NotificationLogQuery fields [event_type:String, status:String, q:String, start_date:String, end_date:String, limit:i64, offset:i64] (handlers.rs:3607); exact handler backend/src/handlers.rs:3709-3734", + "responseSchemaEvidence": "Rust return ( StatusCode, Json>, ); exact handler backend/src/handlers.rs:3709-3734", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "notifications", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:835-836", + "backend/src/handlers.rs:3709-3734" + ], + "upstreamDomain": "notifications", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postNotificationsLogsClear", + "method": "POST", + "pathTemplate": "/api/notifications/logs/clear", + "handler": "clear_notification_logs_handler", + "capability": "job", + "riskLevel": "R3", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:3737-3760", + "responseSchemaEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:3737-3760", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "conditional", + "confirmationPolicy": "strong", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "notifications", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:839-840", + "backend/src/handlers.rs:3737-3760" + ], + "upstreamDomain": "notifications", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getNotificationsQueue", + "method": "GET", + "pathTemplate": "/api/notifications/queue", + "handler": "get_notification_queue_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "query extractor NotificationQueueQuery fields [limit:i64] (notification_queue.rs:14); exact handler backend/src/notification_queue.rs:24-41", + "responseSchemaEvidence": "Rust return ( StatusCode, Json>, ); exact handler backend/src/notification_queue.rs:24-41", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "notifications", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:843-844", + "backend/src/notification_queue.rs:24-41" + ], + "upstreamDomain": "notifications", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postNotificationsQueueRetryAll", + "method": "POST", + "pathTemplate": "/api/notifications/queue/retry-all", + "handler": "retry_all_notification_queue_handler", + "capability": "job", + "riskLevel": "R2", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/notification_queue.rs:84-100", + "responseSchemaEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/notification_queue.rs:84-100", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "non-idempotent", + "confirmationPolicy": "explicit", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "notifications", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:847-848", + "backend/src/notification_queue.rs:84-100" + ], + "upstreamDomain": "notifications", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postNotificationsQueueClear", + "method": "POST", + "pathTemplate": "/api/notifications/queue/clear", + "handler": "clear_notification_queue_handler", + "capability": "job", + "riskLevel": "R3", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/notification_queue.rs:103-119", + "responseSchemaEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/notification_queue.rs:103-119", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "conditional", + "confirmationPolicy": "strong", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "notifications", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:851-852", + "backend/src/notification_queue.rs:103-119" + ], + "upstreamDomain": "notifications", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "deleteNotificationsQueueId", + "method": "DELETE", + "pathTemplate": "/api/notifications/queue/{id}", + "handler": "delete_notification_queue_item_handler", + "capability": "job", + "riskLevel": "R3", + "sessionSensitive": false, + "requestSchemaEvidence": "path extractor i64; exact handler backend/src/notification_queue.rs:64-81", + "responseSchemaEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/notification_queue.rs:64-81", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "idempotent", + "confirmationPolicy": "strong", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "notifications", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:855-856", + "backend/src/notification_queue.rs:64-81" + ], + "upstreamDomain": "notifications", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postNotificationsQueueIdRetry", + "method": "POST", + "pathTemplate": "/api/notifications/queue/{id}/retry", + "handler": "retry_notification_queue_item_handler", + "capability": "job", + "riskLevel": "R2", + "sessionSensitive": false, + "requestSchemaEvidence": "path extractor i64; exact handler backend/src/notification_queue.rs:44-61", + "responseSchemaEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/notification_queue.rs:44-61", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "non-idempotent", + "confirmationPolicy": "explicit", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "notifications", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:859-860", + "backend/src/notification_queue.rs:44-61" + ], + "upstreamDomain": "notifications", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getAutomationConfig", + "method": "GET", + "pathTemplate": "/api/automation/config", + "handler": "get_automation_config_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:3968-3979", + "responseSchemaEvidence": "Rust return ( StatusCode, Json>, ); exact handler backend/src/handlers.rs:3968-3979", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [ + "$.response.data.tasks[*].config" + ], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "automation", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:864-865", + "backend/src/handlers.rs:3968-3979" + ], + "upstreamDomain": "automation", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postAutomationConfig", + "method": "POST", + "pathTemplate": "/api/automation/config", + "handler": "set_automation_config_handler", + "capability": "job", + "riskLevel": "R2", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor crate::config::AutomationConfig fields [enabled:bool, tasks:Vec] (config.rs:1394); exact handler backend/src/handlers.rs:3982-3999", + "responseSchemaEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:3982-3999", + "requestContentType": "application/json", + "timeoutMs": 10000, + "idempotency": "conditional", + "confirmationPolicy": "explicit", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [ + "$.body.tasks[*].config" + ], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "automation", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:864-866", + "backend/src/handlers.rs:3982-3999" + ], + "upstreamDomain": "automation", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getAutomationLogs", + "method": "GET", + "pathTemplate": "/api/automation/logs", + "handler": "get_automation_logs_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "query extractor AutomationLogQuery fields [task_type:String, status:String, q:String, start_date:String, end_date:String, limit:i64, offset:i64] (handlers.rs:3938); exact handler backend/src/handlers.rs:4002-4027", + "responseSchemaEvidence": "Rust return ( StatusCode, Json>, ); exact handler backend/src/handlers.rs:4002-4027", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "automation", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:870-871", + "backend/src/handlers.rs:4002-4027" + ], + "upstreamDomain": "automation", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postAutomationLogsClear", + "method": "POST", + "pathTemplate": "/api/automation/logs/clear", + "handler": "clear_automation_logs_handler", + "capability": "job", + "riskLevel": "R3", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:4030-4053", + "responseSchemaEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:4030-4053", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "conditional", + "confirmationPolicy": "strong", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "automation", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:874-875", + "backend/src/handlers.rs:4030-4053" + ], + "upstreamDomain": "automation", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postAutomationTestTaskId", + "method": "POST", + "pathTemplate": "/api/automation/test/{task_id}", + "handler": "test_automation_task_handler", + "capability": "job", + "riskLevel": "R2", + "sessionSensitive": false, + "requestSchemaEvidence": "path extractor String; exact handler backend/src/handlers.rs:4056-4146", + "responseSchemaEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:4056-4146", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "non-idempotent", + "confirmationPolicy": "explicit", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "automation", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:878-879", + "backend/src/handlers.rs:4056-4146" + ], + "upstreamDomain": "automation", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getOtaStatus", + "method": "GET", + "pathTemplate": "/api/ota/status", + "handler": "get_ota_status_handler", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:3763-3769", + "responseSchemaEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:3763-3769; field shape is implementation evidence only", + "requestContentType": "none", + "timeoutMs": 30000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "ota", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:882-883", + "backend/src/handlers.rs:3763-3769", + "bruno-api/get_ota_status.bru:8" + ], + "upstreamDomain": "ota", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postOtaUpload", + "method": "POST", + "pathTemplate": "/api/ota/upload", + "handler": "upload_ota_handler", + "capability": "job", + "riskLevel": "R3", + "sessionSensitive": false, + "requestSchemaEvidence": "bytes extractor axum::body::Bytes; exact exact handler backend/src/handlers.rs:3772-3792", + "responseSchemaEvidence": "ApiResponse success payload/error envelope; success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:3772-3792", + "requestContentType": "application/octet-stream", + "timeoutMs": 30000, + "idempotency": "non-idempotent", + "confirmationPolicy": "strong", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "ota", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:886-887", + "backend/src/handlers.rs:3772-3792" + ], + "upstreamDomain": "ota", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postOtaLatestRelease", + "method": "POST", + "pathTemplate": "/api/ota/latest-release", + "handler": "get_latest_ota_release_handler", + "capability": "command", + "riskLevel": "R1", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor crate::models::OtaOnlinePrepareRequest fields [proxy_prefix:Option] (models.rs:986); exact handler backend/src/handlers.rs:3795-3825", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:3795-3825", + "requestContentType": "application/json", + "timeoutMs": 30000, + "idempotency": "non-idempotent", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "ota", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:892-893", + "backend/src/handlers.rs:3795-3825" + ], + "upstreamDomain": "ota", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postOtaOnlinePrepare", + "method": "POST", + "pathTemplate": "/api/ota/online-prepare", + "handler": "prepare_online_ota_handler", + "capability": "job", + "riskLevel": "R2", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor crate::models::OtaOnlinePrepareRequest fields [proxy_prefix:Option] (models.rs:986); exact handler backend/src/handlers.rs:3828-3889", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:3828-3889", + "requestContentType": "application/json", + "timeoutMs": 30000, + "idempotency": "non-idempotent", + "confirmationPolicy": "explicit", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "ota", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:896-897", + "backend/src/handlers.rs:3828-3889" + ], + "upstreamDomain": "ota", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postOtaApply", + "method": "POST", + "pathTemplate": "/api/ota/apply", + "handler": "apply_ota_handler", + "capability": "job", + "riskLevel": "R3", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor crate::models::OtaApplyRequest fields [restart_now:bool] (models.rs:1030); exact handler backend/src/handlers.rs:3892-3911", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:3892-3911", + "requestContentType": "application/json", + "timeoutMs": 30000, + "idempotency": "non-idempotent", + "confirmationPolicy": "strong", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "ota", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:900-901", + "backend/src/handlers.rs:3892-3911", + "bruno-api/post_ota_apply_restart.bru:8", + "bruno-api/post_ota_apply.bru:8" + ], + "upstreamDomain": "ota", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postOtaCancel", + "method": "POST", + "pathTemplate": "/api/ota/cancel", + "handler": "cancel_ota_handler", + "capability": "command", + "riskLevel": "R1", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:3914-3931", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:3914-3931", + "requestContentType": "none", + "timeoutMs": 30000, + "idempotency": "conditional", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "ota", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:904-905", + "backend/src/handlers.rs:3914-3931", + "bruno-api/post_ota_cancel.bru:8" + ], + "upstreamDomain": "ota", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postAuthPassword", + "method": "POST", + "pathTemplate": "/api/auth/password", + "handler": "auth::change_password", + "capability": "job", + "riskLevel": "R3", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor ChangePasswordRequest fields [new_password:String] (auth.rs:59); exact handler backend/src/auth.rs:491-553", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/auth.rs:491-553", + "requestContentType": "application/json", + "timeoutMs": 10000, + "idempotency": "conditional", + "confirmationPolicy": "strong", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [ + "$.body.new_password", + "$.response.headers.set-cookie" + ], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "settings-instance", + "executionPolicy": "dedicatedFlow", + "sourceEvidence": [ + "backend/src/main.rs:908-909", + "backend/src/auth.rs:491-553", + "backend/src/auth.rs:43-43" + ], + "upstreamDomain": "instances-auth", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getAuthSettings", + "method": "GET", + "pathTemplate": "/api/auth/settings", + "handler": "auth::get_settings", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/auth.rs:555-570", + "responseSchemaEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/auth.rs:555-570", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [ + "$.request.headers.cookie" + ], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "settings-instance", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:912-913", + "backend/src/auth.rs:555-570", + "backend/src/auth.rs:43-43" + ], + "upstreamDomain": "instances-auth", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postAuthSettings", + "method": "POST", + "pathTemplate": "/api/auth/settings", + "handler": "auth::set_settings", + "capability": "job", + "riskLevel": "R2", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor SecurityConfig fields [password_protection_enabled:bool, password_min_length:u8, password_require_letters:bool, password_require_digits:bool, password_require_symbols:bool, session_ttl_seconds:i64, idle_timeout_seconds:i64] (config.rs:615); exact handler backend/src/auth.rs:572-640", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/auth.rs:572-640", + "requestContentType": "application/json", + "timeoutMs": 10000, + "idempotency": "conditional", + "confirmationPolicy": "explicit", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [ + "$.request.headers.cookie" + ], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "settings-instance", + "executionPolicy": "dedicatedFlow", + "sourceEvidence": [ + "backend/src/main.rs:912-914", + "backend/src/auth.rs:572-640", + "backend/src/auth.rs:43-43" + ], + "upstreamDomain": "instances-auth", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getHealth", + "method": "GET", + "pathTemplate": "/api/health", + "handler": "health_check", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:65-75", + "responseSchemaEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:65-75; field shape is implementation evidence only", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "instances-new", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:923-923", + "backend/src/handlers.rs:65-75", + "bruno-api/get_health.bru:8", + "backend/src/auth.rs:43-43" + ], + "upstreamDomain": "instances-auth", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "getAuthStatus", + "method": "GET", + "pathTemplate": "/api/auth/status", + "handler": "auth::status", + "capability": "query", + "riskLevel": "R0", + "sessionSensitive": false, + "requestSchemaEvidence": "none; no body/query/path extractor; exact handler backend/src/auth.rs:345-364", + "responseSchemaEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/auth.rs:345-364", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "safe", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [ + "$.request.headers.cookie" + ], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "instances-new", + "executionPolicy": "registeredOperation", + "sourceEvidence": [ + "backend/src/main.rs:925-926", + "backend/src/auth.rs:345-364", + "backend/src/auth.rs:43-43" + ], + "upstreamDomain": "instances-auth", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postAuthSetup", + "method": "POST", + "pathTemplate": "/api/auth/setup", + "handler": "auth::setup", + "capability": "job", + "riskLevel": "R3", + "sessionSensitive": false, + "requestSchemaEvidence": "body extractor LoginRequest fields [password:String] (auth.rs:54); exact handler backend/src/auth.rs:366-427", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/auth.rs:366-427", + "requestContentType": "application/json", + "timeoutMs": 10000, + "idempotency": "conditional", + "confirmationPolicy": "strong", + "batchable": false, + "syncSafe": false, + "sensitiveFields": [ + "$.body.password", + "$.response.headers.set-cookie" + ], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "instances-new", + "executionPolicy": "dedicatedFlow", + "sourceEvidence": [ + "backend/src/main.rs:929-930", + "backend/src/auth.rs:366-427", + "backend/src/auth.rs:43-43" + ], + "upstreamDomain": "instances-auth", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3" + }, + { + "operationId": "postAuthLogin", + "method": "POST", + "pathTemplate": "/api/auth/login", + "handler": "auth::login", + "capability": "command", + "riskLevel": "R1", + "sessionSensitive": true, + "requestSchemaEvidence": "body extractor LoginRequest fields [password:String] (auth.rs:54); exact handler backend/src/auth.rs:429-489", + "responseSchemaEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/auth.rs:429-489", + "requestContentType": "application/json", + "timeoutMs": 10000, + "idempotency": "conditional", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [ + "$.body.password", + "$.response.headers.set-cookie" + ], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "instances-new", + "executionPolicy": "dedicatedFlow", + "sourceEvidence": [ + "backend/src/main.rs:933-934", + "backend/src/auth.rs:429-489", + "backend/src/auth.rs:43-43" + ], + "upstreamDomain": "instances-auth", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3", + "automaticReplay": "forbidden" + }, + { + "operationId": "postAuthLogout", + "method": "POST", + "pathTemplate": "/api/auth/logout", + "handler": "auth::logout", + "capability": "command", + "riskLevel": "R1", + "sessionSensitive": true, + "requestSchemaEvidence": "none body; HeaderMap carries simadmin_session cookie; exact handler backend/src/auth.rs:642-655; cookie name backend/src/auth.rs:43-43", + "responseSchemaEvidence": "dynamic-json construction in exact handler backend/src/auth.rs:642-655; field shape is implementation evidence only", + "requestContentType": "none", + "timeoutMs": 10000, + "idempotency": "conditional", + "confirmationPolicy": "none", + "batchable": false, + "syncSafe": true, + "sensitiveFields": [ + "$.request.headers.cookie", + "$.response.headers.set-cookie" + ], + "compatibilityAdapter": "none-pinned-commit", + "uiOwner": "settings-instance", + "executionPolicy": "dedicatedFlow", + "sourceEvidence": [ + "backend/src/main.rs:937-938", + "backend/src/auth.rs:642-655", + "backend/src/auth.rs:43-43" + ], + "upstreamDomain": "instances-auth", + "auditedAtCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "responseFixtureStatus": "pending-phase-0.3", + "automaticReplay": "forbidden" + } +] as const; + +type Direction = 'request' | 'response' | 'path' | 'query' | 'session'; +type SensitiveHint = { symbol?: string; containers?: [string, string, string][]; handlerToken?: string }; +const sensitive = (direction: Direction, path: string, reason: string, redactionMode: 'drop' | 'mask' = 'mask', hint: SensitiveHint = {}) => + ({ direction, path, reason, redactionMode, hint }); + +/* Paths below are deliberately endpoint-specific. They are review policy layered on top of the + * mechanically rebuilt source claims; the snapshot proves the anchors exist, not their semantics. */ +const sensitiveSupplements: Record[]> = { + 'GET /api/sim': ['iccid','imsi','phone_numbers','sms_center'].map(f => sensitive('response', `$.response.data.${f}`, 'SIM/subscriber identifier', 'mask', { symbol: 'SimInfoResponse' })), + 'POST /api/sim/cache': ['phone_number','sms_center'].map(f => sensitive('request', `$.body.${f}`, 'subscriber contact value')), + 'GET /api/sms/list': [sensitive('response','$.response.data.messages[*].phone_number','message correspondent','mask',{symbol:'SmsMessage',containers:[['SmsListResponse','messages','SmsMessage']]}), sensitive('response','$.response.data.messages[*].content','message body','drop',{symbol:'SmsMessage',containers:[['SmsListResponse','messages','SmsMessage']]})], + 'GET /api/sms/conversation': [sensitive('query','$.query.phone_number','message correspondent','mask',{symbol:'SmsConversationRequest'}), sensitive('response','$.response.data.messages[*].phone_number','message correspondent','mask',{symbol:'SmsMessage',containers:[['SmsListResponse','messages','SmsMessage']]}), sensitive('response','$.response.data.messages[*].content','message body','drop',{symbol:'SmsMessage',containers:[['SmsListResponse','messages','SmsMessage']]})], + 'GET /api/calls': [sensitive('response','$.response.data.calls[*].phone_number','call party','mask',{symbol:'CallInfo',containers:[['CallListResponse','calls','CallInfo']]})], + 'GET /api/call/history': [sensitive('response','$.response.data.records[*].phone_number','call party','mask',{symbol:'CallRecord',containers:[['CallHistoryResponse','records','CallRecord']]})], + 'GET /api/call/forwarding': ['voice_unconditional','voice_busy','voice_no_reply','voice_not_reachable'].map(f=>sensitive('response',`$.response.data.${f}`,'forwarding destination','mask',{symbol:'CallForwardingResponse'})), + 'GET /api/voicemail/status': [sensitive('response','$.response.data.mailbox_number','voicemail number','mask',{symbol:'VoicemailStatusResponse'})], + 'GET /api/apn': [sensitive('response','$.response.data.contexts[*].username','APN credential','drop',{symbol:'ApnContext',containers:[['ApnListResponse','contexts','ApnContext']]}), sensitive('response','$.response.data.contexts[*].password','APN credential','drop',{symbol:'ApnContext',containers:[['ApnListResponse','contexts','ApnContext']]})], + 'GET /api/device-network/wlan/status': ['ssid','ipv4_addresses','ipv6_addresses'].map(f => sensitive('response',`$.response.data.${f}`,'network identifier','mask',{symbol:'WlanStatusResponse'})), + 'POST /api/device-network/wlan/scan': ['ssid','bssid'].map(f => sensitive('response',`$.response.data.networks[*].${f}`,'network identifier','mask',{symbol:'WlanNetwork',containers:[['WlanScanResponse','networks','WlanNetwork']]})), + 'GET /api/device-network/wlan/profiles': [sensitive('response','$.response.data.profiles[*].ssid','network identifier','mask',{symbol:'WlanSavedNetwork',containers:[['WlanProfilesResponse','profiles','WlanSavedNetwork']]})], + 'POST /api/device-network/wlan/connect': [sensitive('request','$.body.ssid','network identifier'), sensitive('request','$.body.password','WLAN credential','drop')], + 'GET /api/esim/euicc': [sensitive('response','$.response.data.eid','eUICC identifier','mask',{symbol:'EsimEuiccInfo'})], + 'GET /api/esim/profiles': [sensitive('response','$.response.data.profiles[*].iccid','eSIM identifier','mask',{symbol:'EsimProfile',containers:[['EsimProfilesResponse','profiles','EsimProfile']]})], +}; + +function legacySensitive(path: string) { + const isDrop = /password|secret|token|cookie|content|confirmation_code/i.test(path); + const direction: Direction = path.startsWith('$.body') ? 'request' : path.startsWith('$.query') ? 'query' : + path.startsWith('$.path') ? 'path' : /cookie|session/.test(path) ? 'session' : 'response'; + const canonical = path.replace('$.request.headers.', '$.session.').replace('$.response.headers.', '$.session.'); + return sensitive(direction, canonical, isDrop ? 'secret or message content' : 'identifier or address', isDrop ? 'drop' : 'mask'); +} + +function anchorSensitive(field: ReturnType, operation: any) { + const modelField = field.path.replace(/\[\*\]/g, '').split('.').at(-1)!; + const claim = ['request','path','query'].includes(field.direction) ? operation.requestEvidence : operation.responseEvidence; + const candidates = [...claim.modelEvidence.map((m: any) => m.symbol), ...Object.keys(sensitiveModelEvidence58e2204)]; + const symbol = field.hint.symbol ?? candidates.find(s => (sensitiveModelEvidence58e2204 as any)[s]?.fields?.[modelField]); + if (symbol) { + const model = (sensitiveModelEvidence58e2204 as any)[symbol]; + const containerPath = (field.hint.containers ?? []).map(([owner, linkField, targetSymbol]) => ({ ...(sensitiveModelEvidence58e2204 as any)[owner], field: linkField, targetSymbol })); + return { ...field, sourceAnchor: { symbol, sourceSha256: model.sourceSha256 }, evidence: { ...model, modelField, fieldPath: field.path, containerPath } }; + } + if (field.direction === 'session') { + const sessionEvidence = { sourceFile: 'backend/src/auth.rs', startLine: 43, endLine: 43, sourceSha256: 'b5bc3eefe0f9312882f59ddacf3a062064fdd90e448e5022b81d9ba940a9d266', symbol: 'SESSION_COOKIE', fieldPath: field.path, handlerToken: 'simadmin_session' }; + return { ...field, sourceAnchor: { symbol: 'SESSION_COOKIE', sourceSha256: sessionEvidence.sourceSha256 }, evidence: sessionEvidence }; + } + const fallbackToken = modelField; + return { ...field, sourceAnchor: { symbol: operation.handler, sourceSha256: claim.sourceSha256 }, evidence: { sourceFile: claim.sourceFile, startLine: claim.startLine, endLine: claim.endLine, sourceSha256: claim.sourceSha256, symbol: operation.handler, fieldPath: field.path, handlerToken: field.hint.handlerToken ?? fallbackToken } }; +} + +export const upstream58e2204Operations = rawUpstream58e2204Operations.map(operation => { + const route = `${operation.method} ${operation.pathTemplate}`; + const evidence = structuredEvidence58e2204[route as keyof typeof structuredEvidence58e2204]; + const merged = [...operation.sensitiveFields.map(legacySensitive), ...(sensitiveSupplements[route] ?? [])] + .filter(field => !(route === 'GET /api/apn' && field.path.startsWith('$.body.'))) + .filter(field => !(route === 'GET /api/device-network/wlan/profiles' && field.path === '$.response.data[*].ssid')) + .filter(field => !(route === 'POST /api/device-network/wlan/profile' && field.path.endsWith('.credentials'))); + const unique = [...new Map(merged.map(field => [`${field.direction}:${field.path}`, field])).values()]; + const withEvidence = { ...operation, ...evidence }; + return { ...withEvidence, semanticBindingReview: 'reviewed-phase-0.2', sensitiveFields: unique.map(field => anchorSensitive(field, withEvidence)) }; +}); diff --git a/packages/operation-registry/test/fixtures/main-routes-58e2204.json b/packages/operation-registry/test/fixtures/main-routes-58e2204.json new file mode 100644 index 0000000..7b9f046 --- /dev/null +++ b/packages/operation-registry/test/fixtures/main-routes-58e2204.json @@ -0,0 +1,709 @@ +{ + "upstreamCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "sourceRange": "backend/src/main.rs:503-940", + "extractionRule": "Axum .route declarations in source range; get/post/delete only; OPTIONS excluded", + "routes": [ + { + "method": "GET", + "path": "/api/device", + "handler": "get_device_info", + "source": "backend/src/main.rs:506-506" + }, + { + "method": "GET", + "path": "/api/sim", + "handler": "get_sim_info", + "source": "backend/src/main.rs:508-508" + }, + { + "method": "POST", + "path": "/api/sim/details/refresh", + "handler": "refresh_sim_details_handler", + "source": "backend/src/main.rs:510-511" + }, + { + "method": "POST", + "path": "/api/sim/cache", + "handler": "update_sim_cache_handler", + "source": "backend/src/main.rs:514-515" + }, + { + "method": "GET", + "path": "/api/network", + "handler": "get_network_info", + "source": "backend/src/main.rs:519-520" + }, + { + "method": "GET", + "path": "/api/cells", + "handler": "get_cells", + "source": "backend/src/main.rs:522-522" + }, + { + "method": "POST", + "path": "/api/cell-monitor/start", + "handler": "start_cell_monitor_handler", + "source": "backend/src/main.rs:524-525" + }, + { + "method": "POST", + "path": "/api/cell-monitor/stop", + "handler": "stop_cell_monitor_handler", + "source": "backend/src/main.rs:528-529" + }, + { + "method": "GET", + "path": "/api/radio-mode", + "handler": "get_radio_mode_handler", + "source": "backend/src/main.rs:532-533" + }, + { + "method": "POST", + "path": "/api/radio-mode", + "handler": "set_radio_mode_handler", + "source": "backend/src/main.rs:532-534" + }, + { + "method": "GET", + "path": "/api/band-lock", + "handler": "get_band_lock_handler", + "source": "backend/src/main.rs:538-539" + }, + { + "method": "POST", + "path": "/api/band-lock", + "handler": "set_band_lock_handler", + "source": "backend/src/main.rs:538-540" + }, + { + "method": "GET", + "path": "/api/network/interfaces", + "handler": "get_network_interfaces_info", + "source": "backend/src/main.rs:544-545" + }, + { + "method": "GET", + "path": "/api/network/connection-addresses", + "handler": "get_network_connection_addresses", + "source": "backend/src/main.rs:548-549" + }, + { + "method": "GET", + "path": "/api/device-network/ddns/config", + "handler": "get_device_ddns_config_handler", + "source": "backend/src/main.rs:552-553" + }, + { + "method": "POST", + "path": "/api/device-network/ddns/config", + "handler": "set_device_ddns_config_handler", + "source": "backend/src/main.rs:552-554" + }, + { + "method": "GET", + "path": "/api/device-network/ddns/status", + "handler": "get_device_ddns_status_handler", + "source": "backend/src/main.rs:558-559" + }, + { + "method": "POST", + "path": "/api/device-network/ddns/sync", + "handler": "sync_device_ddns_handler", + "source": "backend/src/main.rs:562-563" + }, + { + "method": "GET", + "path": "/api/device-network/ddns/logs", + "handler": "get_device_ddns_logs_handler", + "source": "backend/src/main.rs:566-567" + }, + { + "method": "POST", + "path": "/api/device-network/ddns/logs/clear", + "handler": "clear_device_ddns_logs_handler", + "source": "backend/src/main.rs:570-571" + }, + { + "method": "GET", + "path": "/api/device-network/wlan/status", + "handler": "get_device_wlan_status_handler", + "source": "backend/src/main.rs:574-575" + }, + { + "method": "POST", + "path": "/api/device-network/wlan/enabled", + "handler": "set_device_wlan_enabled_handler", + "source": "backend/src/main.rs:578-579" + }, + { + "method": "POST", + "path": "/api/device-network/wlan/scan", + "handler": "scan_device_wlan_handler", + "source": "backend/src/main.rs:582-583" + }, + { + "method": "GET", + "path": "/api/device-network/wlan/profiles", + "handler": "get_device_wlan_profiles_handler", + "source": "backend/src/main.rs:586-587" + }, + { + "method": "POST", + "path": "/api/device-network/wlan/forget", + "handler": "forget_device_wlan_handler", + "source": "backend/src/main.rs:590-591" + }, + { + "method": "POST", + "path": "/api/device-network/wlan/connect", + "handler": "connect_device_wlan_handler", + "source": "backend/src/main.rs:594-595" + }, + { + "method": "POST", + "path": "/api/device-network/wlan/disconnect", + "handler": "disconnect_device_wlan_handler", + "source": "backend/src/main.rs:598-599" + }, + { + "method": "POST", + "path": "/api/device-network/wlan/profile", + "handler": "save_device_wlan_profile_handler", + "source": "backend/src/main.rs:602-603" + }, + { + "method": "GET", + "path": "/api/network/signal-strength", + "handler": "get_signal_strength_handler", + "source": "backend/src/main.rs:606-607" + }, + { + "method": "GET", + "path": "/api/location/cell-info", + "handler": "get_cell_location_handler", + "source": "backend/src/main.rs:610-611" + }, + { + "method": "GET", + "path": "/api/network/operators", + "handler": "get_network_operators", + "source": "backend/src/main.rs:614-615" + }, + { + "method": "GET", + "path": "/api/network/operators/scan", + "handler": "scan_network_operators", + "source": "backend/src/main.rs:618-619" + }, + { + "method": "POST", + "path": "/api/network/register-manual", + "handler": "register_network_manual", + "source": "backend/src/main.rs:622-623" + }, + { + "method": "POST", + "path": "/api/network/register-auto", + "handler": "register_network_auto", + "source": "backend/src/main.rs:626-627" + }, + { + "method": "GET", + "path": "/api/apn", + "handler": "get_apn_list_handler", + "source": "backend/src/main.rs:630-631" + }, + { + "method": "POST", + "path": "/api/apn", + "handler": "set_apn_handler", + "source": "backend/src/main.rs:630-632" + }, + { + "method": "GET", + "path": "/api/cell-lock", + "handler": "get_cell_lock_status_handler", + "source": "backend/src/main.rs:636-637" + }, + { + "method": "POST", + "path": "/api/cell-lock", + "handler": "set_cell_lock_handler", + "source": "backend/src/main.rs:636-638" + }, + { + "method": "POST", + "path": "/api/cell-lock/unlock-all", + "handler": "unlock_all_cells_handler", + "source": "backend/src/main.rs:642-643" + }, + { + "method": "GET", + "path": "/api/data", + "handler": "get_data_status", + "source": "backend/src/main.rs:647-648" + }, + { + "method": "POST", + "path": "/api/data", + "handler": "set_data_status", + "source": "backend/src/main.rs:647-649" + }, + { + "method": "GET", + "path": "/api/roaming", + "handler": "get_roaming_status_handler", + "source": "backend/src/main.rs:653-654" + }, + { + "method": "POST", + "path": "/api/roaming", + "handler": "set_roaming_status_handler", + "source": "backend/src/main.rs:653-655" + }, + { + "method": "GET", + "path": "/api/airplane-mode", + "handler": "get_airplane_mode_handler", + "source": "backend/src/main.rs:659-660" + }, + { + "method": "POST", + "path": "/api/airplane-mode", + "handler": "set_airplane_mode_handler", + "source": "backend/src/main.rs:659-661" + }, + { + "method": "POST", + "path": "/api/baseband/restart", + "handler": "restart_baseband_handler", + "source": "backend/src/main.rs:665-666" + }, + { + "method": "GET", + "path": "/api/baseband/restart/status", + "handler": "get_baseband_restart_status_handler", + "source": "backend/src/main.rs:669-670" + }, + { + "method": "GET", + "path": "/api/work-mode", + "handler": "get_work_mode_handler", + "source": "backend/src/main.rs:674-675" + }, + { + "method": "POST", + "path": "/api/work-mode", + "handler": "set_work_mode_handler", + "source": "backend/src/main.rs:674-676" + }, + { + "method": "GET", + "path": "/api/esim/config", + "handler": "get_esim_config_handler", + "source": "backend/src/main.rs:680-681" + }, + { + "method": "POST", + "path": "/api/esim/config", + "handler": "set_esim_config_handler", + "source": "backend/src/main.rs:680-682" + }, + { + "method": "GET", + "path": "/api/esim/lpac/status", + "handler": "get_esim_lpac_status_handler", + "source": "backend/src/main.rs:686-687" + }, + { + "method": "POST", + "path": "/api/esim/lpac/repair", + "handler": "repair_esim_lpac_handler", + "source": "backend/src/main.rs:690-691" + }, + { + "method": "GET", + "path": "/api/esim/euicc", + "handler": "get_esim_euicc_handler", + "source": "backend/src/main.rs:694-695" + }, + { + "method": "GET", + "path": "/api/esim/profiles", + "handler": "get_esim_profiles_handler", + "source": "backend/src/main.rs:698-699" + }, + { + "method": "POST", + "path": "/api/esim/profiles", + "handler": "download_esim_profile_handler", + "source": "backend/src/main.rs:698-700" + }, + { + "method": "POST", + "path": "/api/esim/profiles/{iccid}/enable", + "handler": "enable_esim_profile_handler", + "source": "backend/src/main.rs:704-705" + }, + { + "method": "POST", + "path": "/api/esim/profiles/{iccid}/rename", + "handler": "rename_esim_profile_handler", + "source": "backend/src/main.rs:708-709" + }, + { + "method": "DELETE", + "path": "/api/esim/profiles/{iccid}", + "handler": "delete_esim_profile_handler", + "source": "backend/src/main.rs:712-713" + }, + { + "method": "GET", + "path": "/api/calls", + "handler": "get_calls_handler", + "source": "backend/src/main.rs:717-718" + }, + { + "method": "POST", + "path": "/api/call/dial", + "handler": "dial_call_handler", + "source": "backend/src/main.rs:721-722" + }, + { + "method": "POST", + "path": "/api/call/hangup", + "handler": "hangup_call_handler", + "source": "backend/src/main.rs:725-726" + }, + { + "method": "POST", + "path": "/api/call/hangup-all", + "handler": "hangup_all_calls_handler", + "source": "backend/src/main.rs:729-730" + }, + { + "method": "POST", + "path": "/api/call/answer", + "handler": "answer_call_handler", + "source": "backend/src/main.rs:733-734" + }, + { + "method": "GET", + "path": "/api/call/volume", + "handler": "get_call_volume_handler", + "source": "backend/src/main.rs:737-738" + }, + { + "method": "POST", + "path": "/api/call/volume", + "handler": "set_call_volume_handler", + "source": "backend/src/main.rs:737-739" + }, + { + "method": "GET", + "path": "/api/call/forwarding", + "handler": "get_call_forwarding_handler", + "source": "backend/src/main.rs:743-744" + }, + { + "method": "POST", + "path": "/api/call/forwarding", + "handler": "set_call_forwarding_handler", + "source": "backend/src/main.rs:743-745" + }, + { + "method": "GET", + "path": "/api/call/settings", + "handler": "get_call_settings_handler", + "source": "backend/src/main.rs:749-750" + }, + { + "method": "POST", + "path": "/api/call/settings", + "handler": "set_call_settings_handler", + "source": "backend/src/main.rs:749-751" + }, + { + "method": "GET", + "path": "/api/call/history", + "handler": "get_call_history_handler", + "source": "backend/src/main.rs:755-756" + }, + { + "method": "DELETE", + "path": "/api/call/history/{id}", + "handler": "delete_call_history_handler", + "source": "backend/src/main.rs:759-760" + }, + { + "method": "POST", + "path": "/api/call/history/clear", + "handler": "clear_call_history_handler", + "source": "backend/src/main.rs:763-764" + }, + { + "method": "GET", + "path": "/api/ims/status", + "handler": "get_ims_status_handler", + "source": "backend/src/main.rs:767-768" + }, + { + "method": "GET", + "path": "/api/voicemail/status", + "handler": "get_voicemail_status_handler", + "source": "backend/src/main.rs:771-772" + }, + { + "method": "POST", + "path": "/api/sms/send", + "handler": "send_sms_handler", + "source": "backend/src/main.rs:776-777" + }, + { + "method": "GET", + "path": "/api/sms/list", + "handler": "get_sms_list_handler", + "source": "backend/src/main.rs:780-781" + }, + { + "method": "GET", + "path": "/api/sms/conversation", + "handler": "get_sms_conversation_handler", + "source": "backend/src/main.rs:784-785" + }, + { + "method": "GET", + "path": "/api/sms/stats", + "handler": "get_sms_stats_handler", + "source": "backend/src/main.rs:788-789" + }, + { + "method": "POST", + "path": "/api/sms/batch-delete", + "handler": "delete_sms_batch_handler", + "source": "backend/src/main.rs:792-793" + }, + { + "method": "DELETE", + "path": "/api/sms/conversation/{phone_number}", + "handler": "delete_sms_conversation_handler", + "source": "backend/src/main.rs:796-797" + }, + { + "method": "DELETE", + "path": "/api/sms/message/{id}", + "handler": "delete_sms_message_handler", + "source": "backend/src/main.rs:800-801" + }, + { + "method": "POST", + "path": "/api/sms/clear", + "handler": "clear_sms_handler", + "source": "backend/src/main.rs:804-805" + }, + { + "method": "GET", + "path": "/api/stats", + "handler": "get_system_stats", + "source": "backend/src/main.rs:808-808" + }, + { + "method": "GET", + "path": "/api/stats/cpu", + "handler": "get_cpu_info", + "source": "backend/src/main.rs:809-809" + }, + { + "method": "GET", + "path": "/api/connectivity", + "handler": "get_connectivity_check", + "source": "backend/src/main.rs:811-812" + }, + { + "method": "POST", + "path": "/api/system/reboot", + "handler": "system_reboot", + "source": "backend/src/main.rs:815-816" + }, + { + "method": "POST", + "path": "/api/service/restart", + "handler": "restart_service_handler", + "source": "backend/src/main.rs:819-820" + }, + { + "method": "GET", + "path": "/api/notifications/config", + "handler": "get_notification_config_handler", + "source": "backend/src/main.rs:824-825" + }, + { + "method": "POST", + "path": "/api/notifications/config", + "handler": "set_notification_config_handler", + "source": "backend/src/main.rs:824-826" + }, + { + "method": "POST", + "path": "/api/notifications/test/{channel}", + "handler": "test_notification_channel_handler", + "source": "backend/src/main.rs:830-831" + }, + { + "method": "GET", + "path": "/api/notifications/logs", + "handler": "get_notification_logs_handler", + "source": "backend/src/main.rs:835-836" + }, + { + "method": "POST", + "path": "/api/notifications/logs/clear", + "handler": "clear_notification_logs_handler", + "source": "backend/src/main.rs:839-840" + }, + { + "method": "GET", + "path": "/api/notifications/queue", + "handler": "get_notification_queue_handler", + "source": "backend/src/main.rs:843-844" + }, + { + "method": "POST", + "path": "/api/notifications/queue/retry-all", + "handler": "retry_all_notification_queue_handler", + "source": "backend/src/main.rs:847-848" + }, + { + "method": "POST", + "path": "/api/notifications/queue/clear", + "handler": "clear_notification_queue_handler", + "source": "backend/src/main.rs:851-852" + }, + { + "method": "DELETE", + "path": "/api/notifications/queue/{id}", + "handler": "delete_notification_queue_item_handler", + "source": "backend/src/main.rs:855-856" + }, + { + "method": "POST", + "path": "/api/notifications/queue/{id}/retry", + "handler": "retry_notification_queue_item_handler", + "source": "backend/src/main.rs:859-860" + }, + { + "method": "GET", + "path": "/api/automation/config", + "handler": "get_automation_config_handler", + "source": "backend/src/main.rs:864-865" + }, + { + "method": "POST", + "path": "/api/automation/config", + "handler": "set_automation_config_handler", + "source": "backend/src/main.rs:864-866" + }, + { + "method": "GET", + "path": "/api/automation/logs", + "handler": "get_automation_logs_handler", + "source": "backend/src/main.rs:870-871" + }, + { + "method": "POST", + "path": "/api/automation/logs/clear", + "handler": "clear_automation_logs_handler", + "source": "backend/src/main.rs:874-875" + }, + { + "method": "POST", + "path": "/api/automation/test/{task_id}", + "handler": "test_automation_task_handler", + "source": "backend/src/main.rs:878-879" + }, + { + "method": "GET", + "path": "/api/ota/status", + "handler": "get_ota_status_handler", + "source": "backend/src/main.rs:882-883" + }, + { + "method": "POST", + "path": "/api/ota/upload", + "handler": "upload_ota_handler", + "source": "backend/src/main.rs:886-887" + }, + { + "method": "POST", + "path": "/api/ota/latest-release", + "handler": "get_latest_ota_release_handler", + "source": "backend/src/main.rs:892-893" + }, + { + "method": "POST", + "path": "/api/ota/online-prepare", + "handler": "prepare_online_ota_handler", + "source": "backend/src/main.rs:896-897" + }, + { + "method": "POST", + "path": "/api/ota/apply", + "handler": "apply_ota_handler", + "source": "backend/src/main.rs:900-901" + }, + { + "method": "POST", + "path": "/api/ota/cancel", + "handler": "cancel_ota_handler", + "source": "backend/src/main.rs:904-905" + }, + { + "method": "POST", + "path": "/api/auth/password", + "handler": "auth::change_password", + "source": "backend/src/main.rs:908-909" + }, + { + "method": "GET", + "path": "/api/auth/settings", + "handler": "auth::get_settings", + "source": "backend/src/main.rs:912-913" + }, + { + "method": "POST", + "path": "/api/auth/settings", + "handler": "auth::set_settings", + "source": "backend/src/main.rs:912-914" + }, + { + "method": "GET", + "path": "/api/health", + "handler": "health_check", + "source": "backend/src/main.rs:923-923" + }, + { + "method": "GET", + "path": "/api/auth/status", + "handler": "auth::status", + "source": "backend/src/main.rs:925-926" + }, + { + "method": "POST", + "path": "/api/auth/setup", + "handler": "auth::setup", + "source": "backend/src/main.rs:929-930" + }, + { + "method": "POST", + "path": "/api/auth/login", + "handler": "auth::login", + "source": "backend/src/main.rs:933-934" + }, + { + "method": "POST", + "path": "/api/auth/logout", + "handler": "auth::logout", + "source": "backend/src/main.rs:937-938" + } + ] +} diff --git a/packages/operation-registry/test/fixtures/source-evidence-58e2204.json b/packages/operation-registry/test/fixtures/source-evidence-58e2204.json new file mode 100644 index 0000000..55b4097 --- /dev/null +++ b/packages/operation-registry/test/fixtures/source-evidence-58e2204.json @@ -0,0 +1,3313 @@ +{ + "upstreamCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "evidenceLevel": "independent whole-file snapshot; exact handler + extractor + response construction; static implementation evidence, not runtime fixtures", + "operations": [ + { + "method": "GET", + "path": "/api/device", + "handler": "get_device_info", + "routeEvidence": "backend/src/main.rs:506-506", + "handlerEvidence": "backend/src/handlers.rs:1076-1090", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1076-1090", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1076-1090", + "sha256": "7b4d3ba9a85015e504518640ae05ad6014a98aff50166308b4cd348290cca639", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1076, + "endLine": 1090 + }, + "extractorTokens": [ + "State>" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1076-1090", + "brunoEvidence": [ + { + "file": "bruno-api/get_device_info.bru", + "method": "GET", + "path": "/api/device", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "GET", + "path": "/api/sim", + "handler": "get_sim_info", + "routeEvidence": "backend/src/main.rs:508-508", + "handlerEvidence": "backend/src/handlers.rs:1126-1145", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1126-1145", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1126-1145", + "sha256": "db5280e4e6d37c435a5a0b727087e2ed0d2e09a14901566ff62c7a78fe5685c8", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1126, + "endLine": 1145 + }, + "extractorTokens": [ + "State<(Arc, Arc)>" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1126-1145", + "brunoEvidence": [ + { + "file": "bruno-api/get_sim_info.bru", + "method": "GET", + "path": "/api/sim", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "POST", + "path": "/api/sim/details/refresh", + "handler": "refresh_sim_details_handler", + "routeEvidence": "backend/src/main.rs:510-511", + "handlerEvidence": "backend/src/handlers.rs:1148-1164", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1148-1164", + "responseSummary": "dynamic-json construction in exact handler backend/src/handlers.rs:1148-1164; field shape is implementation evidence only", + "sha256": "ab04a42ae36c34953d81c4ae7418ee489cf8b1b1604961dac9015b80e24677a2", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1148, + "endLine": 1164 + }, + "extractorTokens": [ + "State<(Arc, Arc)>" + ], + "responseKind": "dynamic-json", + "responseTypeEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:1148-1164; field shape is implementation evidence only", + "evidenceLevel": "route+handler" + }, + { + "method": "POST", + "path": "/api/sim/cache", + "handler": "update_sim_cache_handler", + "routeEvidence": "backend/src/main.rs:514-515", + "handlerEvidence": "backend/src/handlers.rs:1167-1213", + "requestSummary": "body extractor UpdateSimCacheRequest fields [phone_number:Option, sms_center:Option] (models.rs:1036); exact handler backend/src/handlers.rs:1167-1213", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1167-1213", + "sha256": "b2b5f366ff8b133d4a8cbc7965a5ab50f1ee5a41b23a6c81019824d801a5798f", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1167, + "endLine": 1213 + }, + "extractorTokens": [ + "Json", + "State" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1167-1213", + "evidenceLevel": "route+handler+model" + }, + { + "method": "GET", + "path": "/api/network", + "handler": "get_network_info", + "routeEvidence": "backend/src/main.rs:519-520", + "handlerEvidence": "backend/src/handlers.rs:1218-1232", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1218-1232", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1218-1232", + "sha256": "5d02208a69b790246dfc8d08647fd728517d3e1777acb3cf3dd83f8e589a9e2d", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1218, + "endLine": 1232 + }, + "extractorTokens": [ + "State>" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1218-1232", + "brunoEvidence": [ + { + "file": "bruno-api/get_network_info.bru", + "method": "GET", + "path": "/api/network", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "GET", + "path": "/api/cells", + "handler": "get_cells", + "routeEvidence": "backend/src/main.rs:522-522", + "handlerEvidence": "backend/src/handlers.rs:1235-1249", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1235-1249", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1235-1249", + "sha256": "2a0eb9db873a461af208a2784c9a07fff1a00fcb3666fcaaa8f67840783a8dde", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1235, + "endLine": 1249 + }, + "extractorTokens": [ + "State>" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1235-1249", + "brunoEvidence": [ + { + "file": "bruno-api/get_cells_info.bru", + "method": "GET", + "path": "/api/cells", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "POST", + "path": "/api/cell-monitor/start", + "handler": "start_cell_monitor_handler", + "routeEvidence": "backend/src/main.rs:524-525", + "handlerEvidence": "backend/src/handlers.rs:1252-1282", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1252-1282", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1252-1282", + "sha256": "c70d5916a800de22a643c93e2a9ddb7a6a4eeb9e8ad83fee1fbd3ee7660b78fe", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1252, + "endLine": 1282 + }, + "extractorTokens": [ + "State" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1252-1282", + "evidenceLevel": "route+handler" + }, + { + "method": "POST", + "path": "/api/cell-monitor/stop", + "handler": "stop_cell_monitor_handler", + "routeEvidence": "backend/src/main.rs:528-529", + "handlerEvidence": "backend/src/handlers.rs:1285-1312", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1285-1312", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1285-1312", + "sha256": "81303fb59efb78b8ab8901a3a1f693305ff13fc3048d51138c6a9e054ee73c66", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1285, + "endLine": 1312 + }, + "extractorTokens": [ + "State" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1285-1312", + "evidenceLevel": "route+handler" + }, + { + "method": "GET", + "path": "/api/radio-mode", + "handler": "get_radio_mode_handler", + "routeEvidence": "backend/src/main.rs:532-533", + "handlerEvidence": "backend/src/handlers.rs:1315-1329", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1315-1329", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1315-1329", + "sha256": "caba610280c2850fd8a840114af3c9b3b69c4ba0f9a724a23cdb92c78aa222ea", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1315, + "endLine": 1329 + }, + "extractorTokens": [ + "State>" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1315-1329", + "brunoEvidence": [ + { + "file": "bruno-api/get_radio_mode.bru", + "method": "GET", + "path": "/api/radio-mode", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "POST", + "path": "/api/radio-mode", + "handler": "set_radio_mode_handler", + "routeEvidence": "backend/src/main.rs:532-534", + "handlerEvidence": "backend/src/handlers.rs:1332-1352", + "requestSummary": "body extractor RadioModeRequest fields [mode:RadioMode] (models.rs:351); exact handler backend/src/handlers.rs:1332-1352", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1332-1352", + "sha256": "c41c6134fe1f28b0f018724a009a4df537556f1226be4e27924d7daa10ce3de2", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1332, + "endLine": 1352 + }, + "extractorTokens": [ + "Json", + "State>" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1332-1352", + "brunoEvidence": [ + { + "file": "bruno-api/set_radio_mode_nr.bru", + "method": "POST", + "path": "/api/radio-mode", + "bodyMode": "json", + "bodyFields": [ + "mode" + ] + }, + { + "file": "bruno-api/set_radio_mode_lte.bru", + "method": "POST", + "path": "/api/radio-mode", + "bodyMode": "json", + "bodyFields": [ + "mode" + ] + }, + { + "file": "bruno-api/set_radio_mode_auto.bru", + "method": "POST", + "path": "/api/radio-mode", + "bodyMode": "json", + "bodyFields": [ + "mode" + ] + } + ], + "evidenceLevel": "route+handler+model+bruno" + }, + { + "method": "GET", + "path": "/api/band-lock", + "handler": "get_band_lock_handler", + "routeEvidence": "backend/src/main.rs:538-539", + "handlerEvidence": "backend/src/handlers.rs:1355-1369", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1355-1369", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1355-1369", + "sha256": "6130c68b619423b3303d2fcf24fa6175c979c80c6bb2158460c2455d7b347f72", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1355, + "endLine": 1369 + }, + "extractorTokens": [ + "State>" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1355-1369", + "brunoEvidence": [ + { + "file": "bruno-api/get_band_lock.bru", + "method": "GET", + "path": "/api/band-lock", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "POST", + "path": "/api/band-lock", + "handler": "set_band_lock_handler", + "routeEvidence": "backend/src/main.rs:538-540", + "handlerEvidence": "backend/src/handlers.rs:1372-1392", + "requestSummary": "body extractor BandLockRequest fields [lte_fdd_bands:Vec, lte_tdd_bands:Vec, nr_fdd_bands:Vec, nr_tdd_bands:Vec] (models.rs:377); exact handler backend/src/handlers.rs:1372-1392", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1372-1392", + "sha256": "04504f8b9b2d528e6e367bea397ef722f4f1eb3aa7363946718ea9871ac20277", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1372, + "endLine": 1392 + }, + "extractorTokens": [ + "Json", + "State>" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1372-1392", + "brunoEvidence": [ + { + "file": "bruno-api/set_band_lock_nr_n78.bru", + "method": "POST", + "path": "/api/band-lock", + "bodyMode": "json", + "bodyFields": [ + "lte_fdd_bands", + "lte_tdd_bands", + "nr_fdd_bands", + "nr_tdd_bands" + ] + }, + { + "file": "bruno-api/set_band_lock_lte_b1_b3.bru", + "method": "POST", + "path": "/api/band-lock", + "bodyMode": "json", + "bodyFields": [ + "lte_fdd_bands", + "lte_tdd_bands", + "nr_fdd_bands", + "nr_tdd_bands" + ] + }, + { + "file": "bruno-api/unlock_all_bands.bru", + "method": "POST", + "path": "/api/band-lock", + "bodyMode": "json", + "bodyFields": [ + "lte_fdd_bands", + "lte_tdd_bands", + "nr_fdd_bands", + "nr_tdd_bands" + ] + }, + { + "file": "bruno-api/set_band_lock_lte_nr_mix.bru", + "method": "POST", + "path": "/api/band-lock", + "bodyMode": "json", + "bodyFields": [ + "lte_fdd_bands", + "lte_tdd_bands", + "nr_fdd_bands", + "nr_tdd_bands" + ] + } + ], + "evidenceLevel": "route+handler+model+bruno" + }, + { + "method": "GET", + "path": "/api/network/interfaces", + "handler": "get_network_interfaces_info", + "routeEvidence": "backend/src/main.rs:544-545", + "handlerEvidence": "backend/src/handlers.rs:1619-1644", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1619-1644", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1619-1644", + "sha256": "a1ac88c365ed70bfccbe2efdc5a2788d3d4bb74115c330fdc319c2eebc074521", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1619, + "endLine": 1644 + }, + "extractorTokens": [ + "State>" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1619-1644", + "brunoEvidence": [ + { + "file": "bruno-api/get_network_interfaces.bru", + "method": "GET", + "path": "/api/network/interfaces", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "GET", + "path": "/api/network/connection-addresses", + "handler": "get_network_connection_addresses", + "routeEvidence": "backend/src/main.rs:548-549", + "handlerEvidence": "backend/src/handlers.rs:1647-1666", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1647-1666", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1647-1666", + "sha256": "a5f28ff95121c09750c5fc3f81192a0d15c0a794e16954d2a2783f357319b82f", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1647, + "endLine": 1666 + }, + "extractorTokens": [ + "State>" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1647-1666", + "evidenceLevel": "route+handler" + }, + { + "method": "GET", + "path": "/api/device-network/ddns/config", + "handler": "get_device_ddns_config_handler", + "routeEvidence": "backend/src/main.rs:552-553", + "handlerEvidence": "backend/src/handlers.rs:1669-1679", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1669-1679", + "responseSummary": "dynamic-json construction in exact handler backend/src/handlers.rs:1669-1679; field shape is implementation evidence only", + "sha256": "4ec8e855d61695c9db55ba9afb333005f6bbb4e0bdae5e22988173431d139b1b", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1669, + "endLine": 1679 + }, + "extractorTokens": [ + "State" + ], + "responseKind": "dynamic-json", + "responseTypeEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:1669-1679; field shape is implementation evidence only", + "brunoEvidence": [ + { + "file": "bruno-api/get_device_ddns_config.bru", + "method": "GET", + "path": "/api/device-network/ddns/config", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "POST", + "path": "/api/device-network/ddns/config", + "handler": "set_device_ddns_config_handler", + "routeEvidence": "backend/src/main.rs:552-554", + "handlerEvidence": "backend/src/handlers.rs:1682-1721", + "requestSummary": "body extractor crate::config::DdnsConfig fields [enabled:bool, provider:String, access_id:String, access_secret:String, interval_seconds:u64, ttl:u32, ipv4:DdnsIpConfig, ipv6:DdnsIpConfig] (config.rs:634); exact handler backend/src/handlers.rs:1682-1721", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1682-1721", + "sha256": "10137722721a3ef9bea62ce99f7225143652521a634c928daa7c389978e69d00", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1682, + "endLine": 1721 + }, + "extractorTokens": [ + "Json", + "State" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1682-1721", + "brunoEvidence": [ + { + "file": "bruno-api/set_device_ddns_config.bru", + "method": "POST", + "path": "/api/device-network/ddns/config", + "bodyMode": "json", + "bodyFields": [ + "enabled", + "provider", + "access_id", + "access_secret", + "interval_seconds", + "ttl", + "ipv4", + "ipv6" + ] + } + ], + "evidenceLevel": "route+handler+model+bruno" + }, + { + "method": "GET", + "path": "/api/device-network/ddns/status", + "handler": "get_device_ddns_status_handler", + "routeEvidence": "backend/src/main.rs:558-559", + "handlerEvidence": "backend/src/handlers.rs:1750-1757", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1750-1757", + "responseSummary": "dynamic-json construction in exact handler backend/src/handlers.rs:1750-1757; field shape is implementation evidence only", + "sha256": "02494bf7928a9a23ec81cab75333d7322683628a1c33ea067d7e4ad52177f8f6", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1750, + "endLine": 1757 + }, + "extractorTokens": [ + "State" + ], + "responseKind": "dynamic-json", + "responseTypeEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:1750-1757; field shape is implementation evidence only", + "brunoEvidence": [ + { + "file": "bruno-api/get_device_ddns_status.bru", + "method": "GET", + "path": "/api/device-network/ddns/status", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "POST", + "path": "/api/device-network/ddns/sync", + "handler": "sync_device_ddns_handler", + "routeEvidence": "backend/src/main.rs:562-563", + "handlerEvidence": "backend/src/handlers.rs:1760-1781", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1760-1781", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1760-1781", + "sha256": "43cf9573e2b43aaf431562572de72d81f57bb586d78c69415441859ad0ab0528", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1760, + "endLine": 1781 + }, + "extractorTokens": [ + "State" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1760-1781", + "brunoEvidence": [ + { + "file": "bruno-api/post_device_ddns_sync.bru", + "method": "POST", + "path": "/api/device-network/ddns/sync", + "bodyMode": "json", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "GET", + "path": "/api/device-network/ddns/logs", + "handler": "get_device_ddns_logs_handler", + "routeEvidence": "backend/src/main.rs:566-567", + "handlerEvidence": "backend/src/handlers.rs:1784-1790", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1784-1790", + "responseSummary": "dynamic-json construction in exact handler backend/src/handlers.rs:1784-1790; field shape is implementation evidence only", + "sha256": "ac80f3ada641ca13354b1b857a8eabfa67c15e30f706209bdce9331a7a903d73", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1784, + "endLine": 1790 + }, + "extractorTokens": [ + "State" + ], + "responseKind": "dynamic-json", + "responseTypeEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:1784-1790; field shape is implementation evidence only", + "brunoEvidence": [ + { + "file": "bruno-api/get_device_ddns_logs.bru", + "method": "GET", + "path": "/api/device-network/ddns/logs", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "POST", + "path": "/api/device-network/ddns/logs/clear", + "handler": "clear_device_ddns_logs_handler", + "routeEvidence": "backend/src/main.rs:570-571", + "handlerEvidence": "backend/src/handlers.rs:1793-1802", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1793-1802", + "responseSummary": "dynamic-json construction in exact handler backend/src/handlers.rs:1793-1802; field shape is implementation evidence only", + "sha256": "8ebb7d7cfe772a4e7fdee06da1ba69de668a68f0a0cb6077949e1f1ef7594173", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1793, + "endLine": 1802 + }, + "extractorTokens": [ + "State" + ], + "responseKind": "dynamic-json", + "responseTypeEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:1793-1802; field shape is implementation evidence only", + "brunoEvidence": [ + { + "file": "bruno-api/post_device_ddns_logs_clear.bru", + "method": "POST", + "path": "/api/device-network/ddns/logs/clear", + "bodyMode": "json", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "GET", + "path": "/api/device-network/wlan/status", + "handler": "get_device_wlan_status_handler", + "routeEvidence": "backend/src/main.rs:574-575", + "handlerEvidence": "backend/src/handlers.rs:1805-1819", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1805-1819", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1805-1819", + "sha256": "66741c07471de1f7a414b9771d6ce841fdcaf04d48244ac8cd2e03ecb1612034", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1805, + "endLine": 1819 + }, + "extractorTokens": [], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1805-1819", + "brunoEvidence": [ + { + "file": "bruno-api/get_device_wlan_status.bru", + "method": "GET", + "path": "/api/device-network/wlan/status", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "POST", + "path": "/api/device-network/wlan/enabled", + "handler": "set_device_wlan_enabled_handler", + "routeEvidence": "backend/src/main.rs:578-579", + "handlerEvidence": "backend/src/handlers.rs:1822-1841", + "requestSummary": "body extractor WlanEnabledRequest fields [enabled:bool] (models.rs:606); exact handler backend/src/handlers.rs:1822-1841", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1822-1841", + "sha256": "b6cc1238ca609208e8c2a6f757736617d2ca200e1b8a1725eda59694592582b7", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1822, + "endLine": 1841 + }, + "extractorTokens": [ + "Json" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1822-1841", + "brunoEvidence": [ + { + "file": "bruno-api/set_device_wlan_enabled.bru", + "method": "POST", + "path": "/api/device-network/wlan/enabled", + "bodyMode": "json", + "bodyFields": [ + "enabled" + ] + } + ], + "evidenceLevel": "route+handler+model+bruno" + }, + { + "method": "POST", + "path": "/api/device-network/wlan/scan", + "handler": "scan_device_wlan_handler", + "routeEvidence": "backend/src/main.rs:582-583", + "handlerEvidence": "backend/src/handlers.rs:1844-1858", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1844-1858", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1844-1858", + "sha256": "de90691d7c57a4e9af311144f3573e0ce80b3cc7ee29cc65b71c94c28e7b2651", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1844, + "endLine": 1858 + }, + "extractorTokens": [], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1844-1858", + "brunoEvidence": [ + { + "file": "bruno-api/post_device_wlan_scan.bru", + "method": "POST", + "path": "/api/device-network/wlan/scan", + "bodyMode": "json", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "GET", + "path": "/api/device-network/wlan/profiles", + "handler": "get_device_wlan_profiles_handler", + "routeEvidence": "backend/src/main.rs:586-587", + "handlerEvidence": "backend/src/handlers.rs:1861-1875", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1861-1875", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1861-1875", + "sha256": "caadf1fc1c436c6342722c2955f0588581d26d12d1fc3db107e47e18adac43f5", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1861, + "endLine": 1875 + }, + "extractorTokens": [], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1861-1875", + "brunoEvidence": [ + { + "file": "bruno-api/get_device_wlan_profiles.bru", + "method": "GET", + "path": "/api/device-network/wlan/profiles", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "POST", + "path": "/api/device-network/wlan/forget", + "handler": "forget_device_wlan_handler", + "routeEvidence": "backend/src/main.rs:590-591", + "handlerEvidence": "backend/src/handlers.rs:1878-1897", + "requestSummary": "body extractor WlanForgetRequest fields [uuid:String, connection_id:String] (models.rs:635); exact handler backend/src/handlers.rs:1878-1897", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1878-1897", + "sha256": "f5142f819f6da44230d61c9f5db90c8cb473de954faee08245a2f159d43c8a7e", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1878, + "endLine": 1897 + }, + "extractorTokens": [ + "Json" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1878-1897", + "brunoEvidence": [ + { + "file": "bruno-api/post_device_wlan_forget.bru", + "method": "POST", + "path": "/api/device-network/wlan/forget", + "bodyMode": "json", + "bodyFields": [ + "uuid", + "connection_id" + ] + } + ], + "evidenceLevel": "route+handler+model+bruno" + }, + { + "method": "POST", + "path": "/api/device-network/wlan/connect", + "handler": "connect_device_wlan_handler", + "routeEvidence": "backend/src/main.rs:594-595", + "handlerEvidence": "backend/src/handlers.rs:1900-1955", + "requestSummary": "body extractor WlanConnectRequest fields [ssid:String, password:String, auto_join:bool] (models.rs:611); exact handler backend/src/handlers.rs:1900-1955", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1900-1955", + "sha256": "9c422666a3db49cdf0efd42873ca681ee158b4fb9183b6211c4f9522f0fe23b5", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1900, + "endLine": 1955 + }, + "extractorTokens": [ + "Json", + "State" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1900-1955", + "brunoEvidence": [ + { + "file": "bruno-api/post_device_wlan_connect.bru", + "method": "POST", + "path": "/api/device-network/wlan/connect", + "bodyMode": "json", + "bodyFields": [ + "ssid", + "password", + "auto_join" + ] + } + ], + "evidenceLevel": "route+handler+model+bruno" + }, + { + "method": "POST", + "path": "/api/device-network/wlan/disconnect", + "handler": "disconnect_device_wlan_handler", + "routeEvidence": "backend/src/main.rs:598-599", + "handlerEvidence": "backend/src/handlers.rs:1958-1991", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1958-1991", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1958-1991", + "sha256": "719a709dc8509a35a0e961f55359aa77d07b9e95908d8622e8b369a54ed62505", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1958, + "endLine": 1991 + }, + "extractorTokens": [ + "State" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1958-1991", + "brunoEvidence": [ + { + "file": "bruno-api/post_device_wlan_disconnect.bru", + "method": "POST", + "path": "/api/device-network/wlan/disconnect", + "bodyMode": "json", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "POST", + "path": "/api/device-network/wlan/profile", + "handler": "save_device_wlan_profile_handler", + "routeEvidence": "backend/src/main.rs:602-603", + "handlerEvidence": "backend/src/handlers.rs:1994-2013", + "requestSummary": "body extractor WlanProfileRequest fields [connection_id:String, auto_join:Option, ipv4_mode:Option, ipv4_address:Option, ipv4_prefix:Option, ipv4_gateway:Option] (models.rs:620); exact handler backend/src/handlers.rs:1994-2013", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1994-2013", + "sha256": "b3180f55af718c0404c190cc47c90be10727269ab9ddc7fee9239d24a6088bcd", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1994, + "endLine": 2013 + }, + "extractorTokens": [ + "Json" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1994-2013", + "brunoEvidence": [ + { + "file": "bruno-api/post_device_wlan_profile.bru", + "method": "POST", + "path": "/api/device-network/wlan/profile", + "bodyMode": "json", + "bodyFields": [ + "connection_id", + "auto_join", + "ipv4_mode" + ] + } + ], + "evidenceLevel": "route+handler+model+bruno" + }, + { + "method": "GET", + "path": "/api/network/signal-strength", + "handler": "get_signal_strength_handler", + "routeEvidence": "backend/src/main.rs:606-607", + "handlerEvidence": "backend/src/handlers.rs:2016-2030", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:2016-2030", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2016-2030", + "sha256": "2c04c31da8b4160b8941a524b3016de75c75ae689a28d50f50a664fa5c4af828", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 2016, + "endLine": 2030 + }, + "extractorTokens": [ + "State>" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2016-2030", + "brunoEvidence": [ + { + "file": "bruno-api/get_signal_strength.bru", + "method": "GET", + "path": "/api/network/signal-strength", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "GET", + "path": "/api/location/cell-info", + "handler": "get_cell_location_handler", + "routeEvidence": "backend/src/main.rs:610-611", + "handlerEvidence": "backend/src/handlers.rs:1395-1409", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1395-1409", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1395-1409", + "sha256": "fc748f05e03bf6843eed36f7a4dd6f785811d05e9dd8942fe2d92a4b63c62398", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1395, + "endLine": 1409 + }, + "extractorTokens": [ + "State>" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1395-1409", + "brunoEvidence": [ + { + "file": "bruno-api/get_cell_location_info.bru", + "method": "GET", + "path": "/api/location/cell-info", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "GET", + "path": "/api/network/operators", + "handler": "get_network_operators", + "routeEvidence": "backend/src/main.rs:614-615", + "handlerEvidence": "backend/src/handlers.rs:1412-1426", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1412-1426", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1412-1426", + "sha256": "d3b30db8a501b92aba2d3038e3642eb020412a2ac11e8286dc1f4860ef3c732b", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1412, + "endLine": 1426 + }, + "extractorTokens": [ + "State>" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1412-1426", + "brunoEvidence": [ + { + "file": "bruno-api/get_operators.bru", + "method": "GET", + "path": "/api/network/operators", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "GET", + "path": "/api/network/operators/scan", + "handler": "scan_network_operators", + "routeEvidence": "backend/src/main.rs:618-619", + "handlerEvidence": "backend/src/handlers.rs:1429-1443", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1429-1443", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1429-1443", + "sha256": "4a8bfbd38a943df8dfa9ce69f3bb162874baf7f6f69507d35257c80aff5316e6", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1429, + "endLine": 1443 + }, + "extractorTokens": [ + "State>" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1429-1443", + "brunoEvidence": [ + { + "file": "bruno-api/scan_operators.bru", + "method": "GET", + "path": "/api/network/operators/scan", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "POST", + "path": "/api/network/register-manual", + "handler": "register_network_manual", + "routeEvidence": "backend/src/main.rs:622-623", + "handlerEvidence": "backend/src/handlers.rs:1446-1466", + "requestSummary": "body extractor ManualRegisterRequest fields [mccmnc:String] (models.rs:771); exact handler backend/src/handlers.rs:1446-1466", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1446-1466", + "sha256": "8b78cc782499ff836911c3bb5563c85ae53cc4bb05b418a01ff94f4e16916b4c", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1446, + "endLine": 1466 + }, + "extractorTokens": [ + "Json", + "State>" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1446-1466", + "brunoEvidence": [ + { + "file": "bruno-api/register_operator_manual.bru", + "method": "POST", + "path": "/api/network/register-manual", + "bodyMode": "json", + "bodyFields": [ + "mccmnc" + ] + } + ], + "evidenceLevel": "route+handler+model+bruno" + }, + { + "method": "POST", + "path": "/api/network/register-auto", + "handler": "register_network_auto", + "routeEvidence": "backend/src/main.rs:626-627", + "handlerEvidence": "backend/src/handlers.rs:1469-1486", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1469-1486", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1469-1486", + "sha256": "431aabcc9852603b66f9a3234afb2e76f6e6cc3845ffb4827874aef16f3744d0", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1469, + "endLine": 1486 + }, + "extractorTokens": [ + "State>" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1469-1486", + "brunoEvidence": [ + { + "file": "bruno-api/register_operator_auto.bru", + "method": "POST", + "path": "/api/network/register-auto", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "GET", + "path": "/api/apn", + "handler": "get_apn_list_handler", + "routeEvidence": "backend/src/main.rs:630-631", + "handlerEvidence": "backend/src/handlers.rs:1489-1504", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1489-1504", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1489-1504", + "sha256": "798953ca7e3857d0ea96f60b7882dafd3893dd751f8af07afea7707dc1145631", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1489, + "endLine": 1504 + }, + "extractorTokens": [ + "State" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1489-1504", + "brunoEvidence": [ + { + "file": "bruno-api/get_apn_list.bru", + "method": "GET", + "path": "/api/apn", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "POST", + "path": "/api/apn", + "handler": "set_apn_handler", + "routeEvidence": "backend/src/main.rs:630-632", + "handlerEvidence": "backend/src/handlers.rs:1507-1571", + "requestSummary": "body extractor SetApnRequest fields [context_path:String, apn:Option, protocol:Option, username:Option, password:Option, auth_method:Option] (models.rs:795); exact handler backend/src/handlers.rs:1507-1571", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1507-1571", + "sha256": "6edced468c5f3bcb5181736b235805550d50c76ebf770b7632c6a7a38e65a8d0", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1507, + "endLine": 1571 + }, + "extractorTokens": [ + "Json", + "State" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1507-1571", + "brunoEvidence": [ + { + "file": "bruno-api/set_apn.bru", + "method": "POST", + "path": "/api/apn", + "bodyMode": "json", + "bodyFields": [ + "context_path", + "apn", + "protocol", + "username", + "password", + "auth_method" + ] + } + ], + "evidenceLevel": "route+handler+model+bruno" + }, + { + "method": "GET", + "path": "/api/cell-lock", + "handler": "get_cell_lock_status_handler", + "routeEvidence": "backend/src/main.rs:636-637", + "handlerEvidence": "backend/src/handlers.rs:1574-1582", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1574-1582", + "responseSummary": "dynamic-json construction in exact handler backend/src/handlers.rs:1574-1582; field shape is implementation evidence only", + "sha256": "fe4b4aaedc6e5ccf4d36d0a347b46999ae99290a41efaf0bedf7e37be992591e", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1574, + "endLine": 1582 + }, + "extractorTokens": [ + "State" + ], + "responseKind": "dynamic-json", + "responseTypeEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:1574-1582; field shape is implementation evidence only", + "brunoEvidence": [ + { + "file": "bruno-api/get_cell_lock.bru", + "method": "GET", + "path": "/api/cell-lock", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "POST", + "path": "/api/cell-lock", + "handler": "set_cell_lock_handler", + "routeEvidence": "backend/src/main.rs:636-638", + "handlerEvidence": "backend/src/handlers.rs:1585-1603", + "requestSummary": "body extractor CellLockRequest fields [rat:u8, enable:bool, lock_type:u8, pci:Option, arfcn:Option] (models.rs:405); exact handler backend/src/handlers.rs:1585-1603", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1585-1603", + "sha256": "29ff2739b7d0b58489c537db7c2fc000dabc4ba4251e27ba34e546cf9f9735ce", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1585, + "endLine": 1603 + }, + "extractorTokens": [ + "Json", + "State" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:1585-1603", + "brunoEvidence": [ + { + "file": "bruno-api/set_cell_lock_lte.bru", + "method": "POST", + "path": "/api/cell-lock", + "bodyMode": "json", + "bodyFields": [ + "rat", + "enable", + "arfcn", + "pci" + ] + }, + { + "file": "bruno-api/set_cell_lock_nr.bru", + "method": "POST", + "path": "/api/cell-lock", + "bodyMode": "json", + "bodyFields": [ + "rat", + "enable", + "arfcn", + "pci" + ] + }, + { + "file": "bruno-api/unlock_cell_nr.bru", + "method": "POST", + "path": "/api/cell-lock", + "bodyMode": "json", + "bodyFields": [ + "rat", + "enable" + ] + } + ], + "evidenceLevel": "route+handler+model+bruno" + }, + { + "method": "POST", + "path": "/api/cell-lock/unlock-all", + "handler": "unlock_all_cells_handler", + "routeEvidence": "backend/src/main.rs:642-643", + "handlerEvidence": "backend/src/handlers.rs:1606-1616", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:1606-1616", + "responseSummary": "dynamic-json construction in exact handler backend/src/handlers.rs:1606-1616; field shape is implementation evidence only", + "sha256": "390162e5ce89c0952897c9a14054e33d502f52ad69a61b6becfe7c47f3c8be5d", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 1606, + "endLine": 1616 + }, + "extractorTokens": [ + "State" + ], + "responseKind": "dynamic-json", + "responseTypeEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:1606-1616; field shape is implementation evidence only", + "brunoEvidence": [ + { + "file": "bruno-api/unlock_all_cells.bru", + "method": "POST", + "path": "/api/cell-lock/unlock-all", + "bodyMode": "json", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "GET", + "path": "/api/data", + "handler": "get_data_status", + "routeEvidence": "backend/src/main.rs:647-648", + "handlerEvidence": "backend/src/handlers.rs:2035-2062", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:2035-2062", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2035-2062", + "sha256": "43f00a519bd67c3a500c709747a29286ccf37bdd96abd83541dcd21c3c476420", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 2035, + "endLine": 2062 + }, + "extractorTokens": [ + "State" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2035-2062", + "brunoEvidence": [ + { + "file": "bruno-api/get_data_status.bru", + "method": "GET", + "path": "/api/data", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "POST", + "path": "/api/data", + "handler": "set_data_status", + "routeEvidence": "backend/src/main.rs:647-649", + "handlerEvidence": "backend/src/handlers.rs:2065-2131", + "requestSummary": "body extractor DataConnectionRequest fields [active:bool] (models.rs:234); exact handler backend/src/handlers.rs:2065-2131", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2065-2131", + "sha256": "f1653cfe4442172ca80c97d1223ee4bdc6ed739d2f16e620a26285ab58b395b9", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 2065, + "endLine": 2131 + }, + "extractorTokens": [ + "Json", + "State" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2065-2131", + "brunoEvidence": [ + { + "file": "bruno-api/set_data_status_disable.bru", + "method": "POST", + "path": "/api/data", + "bodyMode": "json", + "bodyFields": [ + "active" + ] + }, + { + "file": "bruno-api/set_data_status_enable.bru", + "method": "POST", + "path": "/api/data", + "bodyMode": "json", + "bodyFields": [ + "active" + ] + } + ], + "evidenceLevel": "route+handler+model+bruno" + }, + { + "method": "GET", + "path": "/api/roaming", + "handler": "get_roaming_status_handler", + "routeEvidence": "backend/src/main.rs:653-654", + "handlerEvidence": "backend/src/handlers.rs:2172-2193", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:2172-2193", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2172-2193", + "sha256": "687ff0cdb729645af41a7b5d6828f1c348da3948163376adca3418e68a7937ae", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 2172, + "endLine": 2193 + }, + "extractorTokens": [ + "State" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2172-2193", + "brunoEvidence": [ + { + "file": "bruno-api/get_roaming_status.bru", + "method": "GET", + "path": "/api/roaming", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "POST", + "path": "/api/roaming", + "handler": "set_roaming_status_handler", + "routeEvidence": "backend/src/main.rs:653-655", + "handlerEvidence": "backend/src/handlers.rs:2196-2247", + "requestSummary": "body extractor RoamingRequest fields [allowed:bool] (models.rs:244); exact handler backend/src/handlers.rs:2196-2247", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2196-2247", + "sha256": "b18b5c87eb24ccc952ff28bbee43f484c4e20c918ddd4f18d043a471ed3309c7", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 2196, + "endLine": 2247 + }, + "extractorTokens": [ + "Json", + "State" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2196-2247", + "brunoEvidence": [ + { + "file": "bruno-api/set_roaming_enable.bru", + "method": "POST", + "path": "/api/roaming", + "bodyMode": "json", + "bodyFields": [ + "allowed" + ] + }, + { + "file": "bruno-api/set_roaming_disable.bru", + "method": "POST", + "path": "/api/roaming", + "bodyMode": "json", + "bodyFields": [ + "allowed" + ] + } + ], + "evidenceLevel": "route+handler+model+bruno" + }, + { + "method": "GET", + "path": "/api/airplane-mode", + "handler": "get_airplane_mode_handler", + "routeEvidence": "backend/src/main.rs:659-660", + "handlerEvidence": "backend/src/handlers.rs:2315-2329", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:2315-2329", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2315-2329", + "sha256": "243c2a63fd99d45cf4729005221137e0b024f72256dbe3ea3ef83bb75bdde620", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 2315, + "endLine": 2329 + }, + "extractorTokens": [ + "State>" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2315-2329", + "brunoEvidence": [ + { + "file": "bruno-api/get_airplane_mode.bru", + "method": "GET", + "path": "/api/airplane-mode", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "POST", + "path": "/api/airplane-mode", + "handler": "set_airplane_mode_handler", + "routeEvidence": "backend/src/main.rs:659-661", + "handlerEvidence": "backend/src/handlers.rs:2250-2312", + "requestSummary": "body extractor AirplaneModeRequest fields [enabled:bool] (models.rs:255); exact handler backend/src/handlers.rs:2250-2312", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2250-2312", + "sha256": "5efb8a55c495ea0be28c8845d4fa3dcecdac3e3d770aaa3ae23140da46f8492a", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 2250, + "endLine": 2312 + }, + "extractorTokens": [ + "Json", + "State" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2250-2312", + "brunoEvidence": [ + { + "file": "bruno-api/set_airplane_mode_disable.bru", + "method": "POST", + "path": "/api/airplane-mode", + "bodyMode": "json", + "bodyFields": [ + "enabled" + ] + }, + { + "file": "bruno-api/set_airplane_mode_enable.bru", + "method": "POST", + "path": "/api/airplane-mode", + "bodyMode": "json", + "bodyFields": [ + "enabled" + ] + } + ], + "evidenceLevel": "route+handler+model+bruno" + }, + { + "method": "POST", + "path": "/api/baseband/restart", + "handler": "restart_baseband_handler", + "routeEvidence": "backend/src/main.rs:665-666", + "handlerEvidence": "backend/src/handlers.rs:2133-2159", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:2133-2159", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2133-2159", + "sha256": "ef18782a4b21d5b4ba5c75334f7fb503c697239a06372235139f3844e629f6fc", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 2133, + "endLine": 2159 + }, + "extractorTokens": [ + "State" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2133-2159", + "evidenceLevel": "route+handler" + }, + { + "method": "GET", + "path": "/api/baseband/restart/status", + "handler": "get_baseband_restart_status_handler", + "routeEvidence": "backend/src/main.rs:669-670", + "handlerEvidence": "backend/src/handlers.rs:2161-2169", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:2161-2169", + "responseSummary": "dynamic-json construction in exact handler backend/src/handlers.rs:2161-2169; field shape is implementation evidence only", + "sha256": "736cc766f31d5d714dc45f720bc682f377ded3075b4886ec0cb5eb82b2e65cf5", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 2161, + "endLine": 2169 + }, + "extractorTokens": [], + "responseKind": "dynamic-json", + "responseTypeEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:2161-2169; field shape is implementation evidence only", + "evidenceLevel": "route+handler" + }, + { + "method": "GET", + "path": "/api/work-mode", + "handler": "get_work_mode_handler", + "routeEvidence": "backend/src/main.rs:674-675", + "handlerEvidence": "backend/src/handlers.rs:352-365", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:352-365", + "responseSummary": "dynamic-json construction in exact handler backend/src/handlers.rs:352-365; field shape is implementation evidence only", + "sha256": "f2d3dca3f43326f8d0b8da91e01dc98483284a34e2d81ec3602e9fa8e1547ffd", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 352, + "endLine": 365 + }, + "extractorTokens": [ + "State" + ], + "responseKind": "dynamic-json", + "responseTypeEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:352-365; field shape is implementation evidence only", + "evidenceLevel": "route+handler" + }, + { + "method": "POST", + "path": "/api/work-mode", + "handler": "set_work_mode_handler", + "routeEvidence": "backend/src/main.rs:674-676", + "handlerEvidence": "backend/src/handlers.rs:368-405", + "requestSummary": "body extractor WorkModeRequest fields [mode:WorkMode, confirm:bool] (models.rs:57); exact handler backend/src/handlers.rs:368-405", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:368-405", + "sha256": "cf17a54e0df74a9d41ddc7d847f7339f4fe2392f4c6c04041bf3cad519931c3c", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 368, + "endLine": 405 + }, + "extractorTokens": [ + "Json", + "State" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:368-405", + "evidenceLevel": "route+handler+model" + }, + { + "method": "GET", + "path": "/api/esim/config", + "handler": "get_esim_config_handler", + "routeEvidence": "backend/src/main.rs:680-681", + "handlerEvidence": "backend/src/handlers.rs:456-462", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:456-462", + "responseSummary": "dynamic-json construction in exact handler backend/src/handlers.rs:456-462; field shape is implementation evidence only", + "sha256": "1be6b526e1a78451fb771cd0ca2ab2013a934d75847157bbf41bd9e2ab359221", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 456, + "endLine": 462 + }, + "extractorTokens": [ + "State" + ], + "responseKind": "dynamic-json", + "responseTypeEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:456-462; field shape is implementation evidence only", + "evidenceLevel": "route+handler" + }, + { + "method": "POST", + "path": "/api/esim/config", + "handler": "set_esim_config_handler", + "routeEvidence": "backend/src/main.rs:680-682", + "handlerEvidence": "backend/src/handlers.rs:465-482", + "requestSummary": "body extractor crate::config::EsimConfig fields [lpac_path:String, custom_memory_total_kb:Option] (config.rs:1660); exact handler backend/src/handlers.rs:465-482", + "responseSummary": "dynamic-json construction in exact handler backend/src/handlers.rs:465-482; field shape is implementation evidence only", + "sha256": "b497f06687977d9fc9eace182b20a5feb3562b3f6a18583e3e62918cd4e85e79", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 465, + "endLine": 482 + }, + "extractorTokens": [ + "Json", + "State" + ], + "responseKind": "dynamic-json", + "responseTypeEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:465-482; field shape is implementation evidence only", + "evidenceLevel": "route+handler+model" + }, + { + "method": "GET", + "path": "/api/esim/lpac/status", + "handler": "get_esim_lpac_status_handler", + "routeEvidence": "backend/src/main.rs:686-687", + "handlerEvidence": "backend/src/handlers.rs:408-416", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:408-416", + "responseSummary": "dynamic-json construction in exact handler backend/src/handlers.rs:408-416; field shape is implementation evidence only", + "sha256": "a4d5143d1f83089b6f29eda71d7dfc499b47d7260492f453eba31f87a64a8241", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 408, + "endLine": 416 + }, + "extractorTokens": [ + "State" + ], + "responseKind": "dynamic-json", + "responseTypeEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:408-416; field shape is implementation evidence only", + "evidenceLevel": "route+handler" + }, + { + "method": "POST", + "path": "/api/esim/lpac/repair", + "handler": "repair_esim_lpac_handler", + "routeEvidence": "backend/src/main.rs:690-691", + "handlerEvidence": "backend/src/handlers.rs:419-453", + "requestSummary": "body extractor EsimLpacRepairRequest fields [proxy_prefix:Option, asset_url:Option] (models.rs:153); exact handler backend/src/handlers.rs:419-453", + "responseSummary": "dynamic-json construction in exact handler backend/src/handlers.rs:419-453; field shape is implementation evidence only", + "sha256": "4d88af6126732e94f424825cd5a3715060973d5aab707f8b9ea9539225199019", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 419, + "endLine": 453 + }, + "extractorTokens": [ + "Json", + "State" + ], + "responseKind": "dynamic-json", + "responseTypeEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:419-453; field shape is implementation evidence only", + "evidenceLevel": "route+handler+model" + }, + { + "method": "GET", + "path": "/api/esim/euicc", + "handler": "get_esim_euicc_handler", + "routeEvidence": "backend/src/main.rs:694-695", + "handlerEvidence": "backend/src/handlers.rs:485-521", + "requestSummary": "query extractor std::collections::HashMap; exact exact handler backend/src/handlers.rs:485-521", + "responseSummary": "dynamic-json construction in exact handler backend/src/handlers.rs:485-521; field shape is implementation evidence only", + "sha256": "7bb950b7fe21268d45b57dc7f422cd39b7e542eb76dc6c9a739a83c4f347556e", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 485, + "endLine": 521 + }, + "extractorTokens": [ + "Query>", + "State" + ], + "responseKind": "dynamic-json", + "responseTypeEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:485-521; field shape is implementation evidence only", + "evidenceLevel": "route+handler" + }, + { + "method": "GET", + "path": "/api/esim/profiles", + "handler": "get_esim_profiles_handler", + "routeEvidence": "backend/src/main.rs:698-699", + "handlerEvidence": "backend/src/handlers.rs:524-598", + "requestSummary": "query extractor std::collections::HashMap; exact exact handler backend/src/handlers.rs:524-598", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:524-598", + "sha256": "07f60eddaa9d20ff8239c0083b15207aec94530c2e1edf998dc709e39b125062", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 524, + "endLine": 598 + }, + "extractorTokens": [ + "Query>", + "State" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:524-598", + "evidenceLevel": "route+handler" + }, + { + "method": "POST", + "path": "/api/esim/profiles", + "handler": "download_esim_profile_handler", + "routeEvidence": "backend/src/main.rs:698-700", + "handlerEvidence": "backend/src/handlers.rs:803-1071", + "requestSummary": "body extractor EsimDownloadRequest fields [smdp:String, matching_id:String, confirmation_code:Option, imei:Option] (models.rs:174); exact handler backend/src/handlers.rs:803-1071", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:803-1071", + "sha256": "fe41273a15be55b3864e2d8f4dfc65d27686e5cafdc20d972603a5800b7a7043", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 803, + "endLine": 1071 + }, + "extractorTokens": [ + "Json", + "State" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:803-1071", + "evidenceLevel": "route+handler+model" + }, + { + "method": "POST", + "path": "/api/esim/profiles/{iccid}/enable", + "handler": "enable_esim_profile_handler", + "routeEvidence": "backend/src/main.rs:704-705", + "handlerEvidence": "backend/src/handlers.rs:601-726", + "requestSummary": "path extractor String; exact handler backend/src/handlers.rs:601-726", + "responseSummary": "dynamic-json construction in exact handler backend/src/handlers.rs:601-726; field shape is implementation evidence only", + "sha256": "d54705546ca3bf2afafe9638d2c33ed198255747e19092a1dd6e1697e691ec2d", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 601, + "endLine": 726 + }, + "extractorTokens": [ + "Path", + "State" + ], + "responseKind": "dynamic-json", + "responseTypeEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:601-726; field shape is implementation evidence only", + "evidenceLevel": "route+handler" + }, + { + "method": "POST", + "path": "/api/esim/profiles/{iccid}/rename", + "handler": "rename_esim_profile_handler", + "routeEvidence": "backend/src/main.rs:708-709", + "handlerEvidence": "backend/src/handlers.rs:729-750", + "requestSummary": "body extractor EsimRenameRequest fields [name:String] (models.rs:169); path extractor String; exact handler backend/src/handlers.rs:729-750", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:729-750", + "sha256": "0d5cb68158e1e6c2f656a687eb43be6ad936a8d920e0a18262c20195ea08cb65", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 729, + "endLine": 750 + }, + "extractorTokens": [ + "Json", + "Path", + "State" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:729-750", + "evidenceLevel": "route+handler+model" + }, + { + "method": "DELETE", + "path": "/api/esim/profiles/{iccid}", + "handler": "delete_esim_profile_handler", + "routeEvidence": "backend/src/main.rs:712-713", + "handlerEvidence": "backend/src/handlers.rs:753-780", + "requestSummary": "path extractor String; exact handler backend/src/handlers.rs:753-780", + "responseSummary": "dynamic-json construction in exact handler backend/src/handlers.rs:753-780; field shape is implementation evidence only", + "sha256": "8ad25b4826f75d27b2e39e8dee2c2362f47f2c9279ff1bd878ec57e2aa18aaf1", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 753, + "endLine": 780 + }, + "extractorTokens": [ + "Path", + "State" + ], + "responseKind": "dynamic-json", + "responseTypeEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:753-780; field shape is implementation evidence only", + "evidenceLevel": "route+handler" + }, + { + "method": "GET", + "path": "/api/calls", + "handler": "get_calls_handler", + "routeEvidence": "backend/src/main.rs:717-718", + "handlerEvidence": "backend/src/handlers.rs:2634-2655", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:2634-2655", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2634-2655", + "sha256": "21e77021b749876bcd614e999f860c7e6826fc9744733cae9cbc65744b839da1", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 2634, + "endLine": 2655 + }, + "extractorTokens": [ + "State" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2634-2655", + "brunoEvidence": [ + { + "file": "bruno-api/get_calls.bru", + "method": "GET", + "path": "/api/calls", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "POST", + "path": "/api/call/dial", + "handler": "dial_call_handler", + "routeEvidence": "backend/src/main.rs:721-722", + "handlerEvidence": "backend/src/handlers.rs:2657-2689", + "requestSummary": "body extractor MakeCallRequest fields [phone_number:String] (models.rs:810); exact handler backend/src/handlers.rs:2657-2689", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2657-2689", + "sha256": "c440bb90bb4373b32a57c6c2d40ebbcb8017853f99b0338c444eaa2f19e00d90", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 2657, + "endLine": 2689 + }, + "extractorTokens": [ + "Json", + "State" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2657-2689", + "brunoEvidence": [ + { + "file": "bruno-api/post_call_dial.bru", + "method": "POST", + "path": "/api/call/dial", + "bodyMode": "json", + "bodyFields": [ + "phone_number" + ] + } + ], + "evidenceLevel": "route+handler+model+bruno" + }, + { + "method": "POST", + "path": "/api/call/hangup", + "handler": "hangup_call_handler", + "routeEvidence": "backend/src/main.rs:725-726", + "handlerEvidence": "backend/src/handlers.rs:2691-2725", + "requestSummary": "body extractor HangupCallRequest fields [path:String] (models.rs:830); exact handler backend/src/handlers.rs:2691-2725", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2691-2725", + "sha256": "cffdc6b2f949af4d1ad38a29693f8c610824bfc2f950540d43399481aa3bb992", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 2691, + "endLine": 2725 + }, + "extractorTokens": [ + "Json", + "State" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2691-2725", + "brunoEvidence": [ + { + "file": "bruno-api/post_call_hangup.bru", + "method": "POST", + "path": "/api/call/hangup", + "bodyMode": "json", + "bodyFields": [ + "path" + ] + } + ], + "evidenceLevel": "route+handler+model+bruno" + }, + { + "method": "POST", + "path": "/api/call/hangup-all", + "handler": "hangup_all_calls_handler", + "routeEvidence": "backend/src/main.rs:729-730", + "handlerEvidence": "backend/src/handlers.rs:2727-2760", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:2727-2760", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2727-2760", + "sha256": "3af8fab7bb4c04370a679b8dc28c435c60c15bba7f6b68fdccbd06e17698e5b5", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 2727, + "endLine": 2760 + }, + "extractorTokens": [ + "State" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2727-2760", + "brunoEvidence": [ + { + "file": "bruno-api/post_call_hangup_all.bru", + "method": "POST", + "path": "/api/call/hangup-all", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "POST", + "path": "/api/call/answer", + "handler": "answer_call_handler", + "routeEvidence": "backend/src/main.rs:733-734", + "handlerEvidence": "backend/src/handlers.rs:2762-2789", + "requestSummary": "body extractor HangupCallRequest fields [path:String] (models.rs:830); exact handler backend/src/handlers.rs:2762-2789", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2762-2789", + "sha256": "cfcda6600d9ba27cb94599a4b0cac37ba4c64a6fb1c85806201e17ff7999c765", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 2762, + "endLine": 2789 + }, + "extractorTokens": [ + "Json", + "State" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2762-2789", + "brunoEvidence": [ + { + "file": "bruno-api/post_call_answer.bru", + "method": "POST", + "path": "/api/call/answer", + "bodyMode": "json", + "bodyFields": [ + "path" + ] + } + ], + "evidenceLevel": "route+handler+model+bruno" + }, + { + "method": "GET", + "path": "/api/call/volume", + "handler": "get_call_volume_handler", + "routeEvidence": "backend/src/main.rs:737-738", + "handlerEvidence": "backend/src/handlers.rs:2899-2906", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:2899-2906", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2899-2906", + "sha256": "4f96c358e70ba5b394df486073b9aa4568634b6f17204a1e1beb46eaa65b6113", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 2899, + "endLine": 2906 + }, + "extractorTokens": [], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2899-2906", + "brunoEvidence": [ + { + "file": "bruno-api/get_call_volume.bru", + "method": "GET", + "path": "/api/call/volume", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "POST", + "path": "/api/call/volume", + "handler": "set_call_volume_handler", + "routeEvidence": "backend/src/main.rs:737-739", + "handlerEvidence": "backend/src/handlers.rs:2908-2922", + "requestSummary": "body extractor SetCallVolumeRequest fields [speaker_volume:Option, microphone_volume:Option, muted:Option] (models.rs:856); exact handler backend/src/handlers.rs:2908-2922", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2908-2922", + "sha256": "9f2e624358e53aaf0adb75e83962b587bd522906d56e597ae9893722505ab582", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 2908, + "endLine": 2922 + }, + "extractorTokens": [ + "Json" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2908-2922", + "brunoEvidence": [ + { + "file": "bruno-api/set_call_volume.bru", + "method": "POST", + "path": "/api/call/volume", + "bodyMode": "json", + "bodyFields": [ + "speaker_volume", + "microphone_volume", + "muted" + ] + } + ], + "evidenceLevel": "route+handler+model+bruno" + }, + { + "method": "GET", + "path": "/api/call/forwarding", + "handler": "get_call_forwarding_handler", + "routeEvidence": "backend/src/main.rs:743-744", + "handlerEvidence": "backend/src/handlers.rs:2924-2931", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:2924-2931", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2924-2931", + "sha256": "f7916eb4c5c484e5df16a44890308b546788b4f281784dc754c6160047786712", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 2924, + "endLine": 2931 + }, + "extractorTokens": [], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2924-2931", + "brunoEvidence": [ + { + "file": "bruno-api/get_call_forwarding.bru", + "method": "GET", + "path": "/api/call/forwarding", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "POST", + "path": "/api/call/forwarding", + "handler": "set_call_forwarding_handler", + "routeEvidence": "backend/src/main.rs:743-745", + "handlerEvidence": "backend/src/handlers.rs:2933-2943", + "requestSummary": "body extractor SetCallForwardingRequest fields [forward_type:String, number:String, timeout:Option] (models.rs:891); exact handler backend/src/handlers.rs:2933-2943", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2933-2943", + "sha256": "ddc8b848bccd96aa0485aed3d8ae828cb0c22f0199fb5e98e22dda87ef866a3d", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 2933, + "endLine": 2943 + }, + "extractorTokens": [ + "Json" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2933-2943", + "brunoEvidence": [ + { + "file": "bruno-api/set_call_forwarding.bru", + "method": "POST", + "path": "/api/call/forwarding", + "bodyMode": "json", + "bodyFields": [ + "forward_type", + "number" + ] + } + ], + "evidenceLevel": "route+handler+model+bruno" + }, + { + "method": "GET", + "path": "/api/call/settings", + "handler": "get_call_settings_handler", + "routeEvidence": "backend/src/main.rs:749-750", + "handlerEvidence": "backend/src/handlers.rs:2852-2866", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:2852-2866", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2852-2866", + "sha256": "893ccecb6c077f7e4c25829113a41bc1d7ddc9766fd813433f0653e32e62f544", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 2852, + "endLine": 2866 + }, + "extractorTokens": [ + "State>" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2852-2866", + "brunoEvidence": [ + { + "file": "bruno-api/get_call_settings.bru", + "method": "GET", + "path": "/api/call/settings", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "POST", + "path": "/api/call/settings", + "handler": "set_call_settings_handler", + "routeEvidence": "backend/src/main.rs:749-751", + "handlerEvidence": "backend/src/handlers.rs:2868-2897", + "requestSummary": "body extractor SetCallSettingRequest fields [property:String, value:String] (models.rs:875); exact handler backend/src/handlers.rs:2868-2897", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2868-2897", + "sha256": "55ab650b3c53b78a3b80606e57d1e96261c5f88c27554c9181f8cd91e212441e", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 2868, + "endLine": 2897 + }, + "extractorTokens": [ + "Json", + "State>" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2868-2897", + "brunoEvidence": [ + { + "file": "bruno-api/set_call_settings.bru", + "method": "POST", + "path": "/api/call/settings", + "bodyMode": "json", + "bodyFields": [ + "property", + "value" + ] + } + ], + "evidenceLevel": "route+handler+model+bruno" + }, + { + "method": "GET", + "path": "/api/call/history", + "handler": "get_call_history_handler", + "routeEvidence": "backend/src/main.rs:755-756", + "handlerEvidence": "backend/src/handlers.rs:2791-2813", + "requestSummary": "query extractor CallHistoryRequest fields [limit:i64, offset:i64] (models.rs:835); exact handler backend/src/handlers.rs:2791-2813", + "responseSummary": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2791-2813", + "sha256": "1def94db4c3d848ebba26d285882e36be6d0ce3a61b525d36b120d9b1a11740a", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 2791, + "endLine": 2813 + }, + "extractorTokens": [ + "Json>", + "Query", + "State>" + ], + "responseKind": "explicit-return", + "responseTypeEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2791-2813", + "evidenceLevel": "route+handler+model" + }, + { + "method": "DELETE", + "path": "/api/call/history/{id}", + "handler": "delete_call_history_handler", + "routeEvidence": "backend/src/main.rs:759-760", + "handlerEvidence": "backend/src/handlers.rs:2815-2832", + "requestSummary": "path extractor i64; exact handler backend/src/handlers.rs:2815-2832", + "responseSummary": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2815-2832", + "sha256": "defa60d157769310f419da59b4abff5813c6e2ebc790b46d03881e305df44971", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 2815, + "endLine": 2832 + }, + "extractorTokens": [ + "Json>", + "Path", + "State>" + ], + "responseKind": "explicit-return", + "responseTypeEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2815-2832", + "brunoEvidence": [ + { + "file": "bruno-api/delete_call_history.bru", + "method": "DELETE", + "path": "/api/call/history/1", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "POST", + "path": "/api/call/history/clear", + "handler": "clear_call_history_handler", + "routeEvidence": "backend/src/main.rs:763-764", + "handlerEvidence": "backend/src/handlers.rs:2834-2850", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:2834-2850", + "responseSummary": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2834-2850", + "sha256": "b0e0002012256b5dba375fdc2f747d07923f191029ff960b09297ddc2a521e73", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 2834, + "endLine": 2850 + }, + "extractorTokens": [ + "Json>", + "State>" + ], + "responseKind": "explicit-return", + "responseTypeEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2834-2850", + "brunoEvidence": [ + { + "file": "bruno-api/clear_call_history.bru", + "method": "POST", + "path": "/api/call/history/clear", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "GET", + "path": "/api/ims/status", + "handler": "get_ims_status_handler", + "routeEvidence": "backend/src/main.rs:767-768", + "handlerEvidence": "backend/src/handlers.rs:2945-2952", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:2945-2952", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2945-2952", + "sha256": "e533ac5c9e7bc694faa94eaddc7de74e75c53270478480e0c53a1031f5087ba1", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 2945, + "endLine": 2952 + }, + "extractorTokens": [], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2945-2952", + "brunoEvidence": [ + { + "file": "bruno-api/get_ims_status.bru", + "method": "GET", + "path": "/api/ims/status", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "GET", + "path": "/api/voicemail/status", + "handler": "get_voicemail_status_handler", + "routeEvidence": "backend/src/main.rs:771-772", + "handlerEvidence": "backend/src/handlers.rs:2954-2961", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:2954-2961", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2954-2961", + "sha256": "837b8fbc971099b904d594fd605ff70aa3f1242a3f26365db95a479d885b33c3", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 2954, + "endLine": 2961 + }, + "extractorTokens": [], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2954-2961", + "brunoEvidence": [ + { + "file": "bruno-api/get_voicemail_status.bru", + "method": "GET", + "path": "/api/voicemail/status", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "POST", + "path": "/api/sms/send", + "handler": "send_sms_handler", + "routeEvidence": "backend/src/main.rs:776-777", + "handlerEvidence": "backend/src/handlers.rs:2373-2404", + "requestSummary": "body extractor SendSmsRequest fields [phone_number:String, content:String] (models.rs:912); exact handler backend/src/handlers.rs:2373-2404", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2373-2404", + "sha256": "6cd7eaa5e1a1f939704cadb96de5c47ac7df8c63a7d87018cf2f0ed8fbcdb156", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 2373, + "endLine": 2404 + }, + "extractorTokens": [ + "Json", + "State>", + "State>" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:2373-2404", + "brunoEvidence": [ + { + "file": "bruno-api/post_sms_send.bru", + "method": "POST", + "path": "/api/sms/send", + "bodyMode": "json", + "bodyFields": [ + "phone_number", + "content" + ] + } + ], + "evidenceLevel": "route+handler+model+bruno" + }, + { + "method": "GET", + "path": "/api/sms/list", + "handler": "get_sms_list_handler", + "routeEvidence": "backend/src/main.rs:780-781", + "handlerEvidence": "backend/src/handlers.rs:2407-2434", + "requestSummary": "query extractor SmsListRequest fields [limit:i64, offset:i64, direction:Option] (models.rs:918); exact handler backend/src/handlers.rs:2407-2434", + "responseSummary": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2407-2434", + "sha256": "abbc14adc02b2f5f0c0cf11a9bbac9ceeddf12871a9be8a00b9e9967923d3576", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 2407, + "endLine": 2434 + }, + "extractorTokens": [ + "Json>", + "Query", + "State>" + ], + "responseKind": "explicit-return", + "responseTypeEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2407-2434", + "evidenceLevel": "route+handler+model" + }, + { + "method": "GET", + "path": "/api/sms/conversation", + "handler": "get_sms_conversation_handler", + "routeEvidence": "backend/src/main.rs:784-785", + "handlerEvidence": "backend/src/handlers.rs:2437-2458", + "requestSummary": "query extractor SmsConversationRequest fields [phone_number:String, limit:i64] (models.rs:927); exact handler backend/src/handlers.rs:2437-2458", + "responseSummary": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2437-2458", + "sha256": "e0747d062889fe8caf7b16d6f394c1a3a4f10148f8df3e2b0b09c80e345bb3da", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 2437, + "endLine": 2458 + }, + "extractorTokens": [ + "Json>", + "Query", + "State>" + ], + "responseKind": "explicit-return", + "responseTypeEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2437-2458", + "evidenceLevel": "route+handler+model" + }, + { + "method": "GET", + "path": "/api/sms/stats", + "handler": "get_sms_stats_handler", + "routeEvidence": "backend/src/main.rs:788-789", + "handlerEvidence": "backend/src/handlers.rs:2461-2477", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:2461-2477", + "responseSummary": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2461-2477", + "sha256": "bd62f399815cb0d1f41cf895ce32e921dd10bc8cde5b30ed95ba9b46a629b9eb", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 2461, + "endLine": 2477 + }, + "extractorTokens": [ + "Json>", + "State>" + ], + "responseKind": "explicit-return", + "responseTypeEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2461-2477", + "brunoEvidence": [ + { + "file": "bruno-api/get_sms_stats.bru", + "method": "GET", + "path": "/api/sms/stats", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "POST", + "path": "/api/sms/batch-delete", + "handler": "delete_sms_batch_handler", + "routeEvidence": "backend/src/main.rs:792-793", + "handlerEvidence": "backend/src/handlers.rs:2551-2578", + "requestSummary": "body extractor SmsBatchDeleteRequest fields [ids:Vec, phone_numbers:Vec] (models.rs:934); exact handler backend/src/handlers.rs:2551-2578", + "responseSummary": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2551-2578", + "sha256": "94c5c834874012034e8bcd6ba0b03f8cd24fb283c3682129568bcbada59d1609", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 2551, + "endLine": 2578 + }, + "extractorTokens": [ + "Json", + "Json>", + "State" + ], + "responseKind": "explicit-return", + "responseTypeEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2551-2578", + "evidenceLevel": "route+handler+model" + }, + { + "method": "DELETE", + "path": "/api/sms/conversation/{phone_number}", + "handler": "delete_sms_conversation_handler", + "routeEvidence": "backend/src/main.rs:796-797", + "handlerEvidence": "backend/src/handlers.rs:2528-2548", + "requestSummary": "path extractor String; exact handler backend/src/handlers.rs:2528-2548", + "responseSummary": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2528-2548", + "sha256": "8abd673088da872031588a6449bba27fc95aac6d74a418eed143adbd1fc8166a", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 2528, + "endLine": 2548 + }, + "extractorTokens": [ + "Json>", + "Path", + "State" + ], + "responseKind": "explicit-return", + "responseTypeEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2528-2548", + "evidenceLevel": "route+handler" + }, + { + "method": "DELETE", + "path": "/api/sms/message/{id}", + "handler": "delete_sms_message_handler", + "routeEvidence": "backend/src/main.rs:800-801", + "handlerEvidence": "backend/src/handlers.rs:2508-2525", + "requestSummary": "path extractor i64; exact handler backend/src/handlers.rs:2508-2525", + "responseSummary": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2508-2525", + "sha256": "8edc9f89854b5c8d8150cb04d5d26935c5bf3131db2cc459a32966c24c6c37f6", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 2508, + "endLine": 2525 + }, + "extractorTokens": [ + "Json>", + "Path", + "State>" + ], + "responseKind": "explicit-return", + "responseTypeEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2508-2525", + "evidenceLevel": "route+handler" + }, + { + "method": "POST", + "path": "/api/sms/clear", + "handler": "clear_sms_handler", + "routeEvidence": "backend/src/main.rs:804-805", + "handlerEvidence": "backend/src/handlers.rs:2480-2505", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:2480-2505", + "responseSummary": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2480-2505", + "sha256": "8c536214c43b65fc5ae19a81c26c7f3b007d6da6eada9f3908f0f9ddcd81bb57", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 2480, + "endLine": 2505 + }, + "extractorTokens": [ + "Json>", + "State" + ], + "responseKind": "explicit-return", + "responseTypeEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:2480-2505", + "brunoEvidence": [ + { + "file": "bruno-api/post_sms_clear.bru", + "method": "POST", + "path": "/api/sms/clear", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "GET", + "path": "/api/stats", + "handler": "get_system_stats", + "routeEvidence": "backend/src/main.rs:808-808", + "handlerEvidence": "backend/src/handlers.rs:3272-3288", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:3272-3288", + "responseSummary": "dynamic-json construction in exact handler backend/src/handlers.rs:3272-3288; field shape is implementation evidence only", + "sha256": "94834f33d10be4b67c6dbc13d948ea17094235847d6e6cab92f426ff56f8af51", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 3272, + "endLine": 3288 + }, + "extractorTokens": [ + "State>" + ], + "responseKind": "dynamic-json", + "responseTypeEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:3272-3288; field shape is implementation evidence only", + "brunoEvidence": [ + { + "file": "bruno-api/get_stats.bru", + "method": "GET", + "path": "/api/stats", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "GET", + "path": "/api/stats/cpu", + "handler": "get_cpu_info", + "routeEvidence": "backend/src/main.rs:809-809", + "handlerEvidence": "backend/src/handlers.rs:3291-3302", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:3291-3302", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:3291-3302", + "sha256": "9f24be45c5c8df7a153d7542ec26c95a0a92af09ff5246e3650d985adb634b77", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 3291, + "endLine": 3302 + }, + "extractorTokens": [], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:3291-3302", + "brunoEvidence": [ + { + "file": "bruno-api/get_cpu_info.bru", + "method": "GET", + "path": "/api/stats/cpu", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "GET", + "path": "/api/connectivity", + "handler": "get_connectivity_check", + "routeEvidence": "backend/src/main.rs:811-812", + "handlerEvidence": "backend/src/handlers.rs:3305-3322", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:3305-3322", + "responseSummary": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:3305-3322", + "sha256": "42ba5f81f052b9923867ee194593504b9f390c1972b08483e4bec85c017303a4", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 3305, + "endLine": 3322 + }, + "extractorTokens": [ + "Json>" + ], + "responseKind": "explicit-return", + "responseTypeEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:3305-3322", + "evidenceLevel": "route+handler" + }, + { + "method": "POST", + "path": "/api/system/reboot", + "handler": "system_reboot", + "routeEvidence": "backend/src/main.rs:815-816", + "handlerEvidence": "backend/src/handlers.rs:3381-3406", + "requestSummary": "body extractor SystemRebootRequest fields [delay_seconds:u32] (models.rs:422); exact handler backend/src/handlers.rs:3381-3406", + "responseSummary": "dynamic-json construction in exact handler backend/src/handlers.rs:3381-3406; field shape is implementation evidence only", + "sha256": "362737633fd01332829b2623f8aa0bd398846a3648fb431f70c161ee1bb6fe1e", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 3381, + "endLine": 3406 + }, + "extractorTokens": [ + "Json", + "State" + ], + "responseKind": "dynamic-json", + "responseTypeEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:3381-3406; field shape is implementation evidence only", + "brunoEvidence": [ + { + "file": "bruno-api/post_system_reboot.bru", + "method": "POST", + "path": "/api/system/reboot", + "bodyMode": "json", + "bodyFields": [ + "delay_seconds" + ] + } + ], + "evidenceLevel": "route+handler+model+bruno" + }, + { + "method": "POST", + "path": "/api/service/restart", + "handler": "restart_service_handler", + "routeEvidence": "backend/src/main.rs:819-820", + "handlerEvidence": "backend/src/handlers.rs:3578-3601", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:3578-3601", + "responseSummary": "dynamic-json construction in exact handler backend/src/handlers.rs:3578-3601; field shape is implementation evidence only", + "sha256": "712a81c6cca94e0cd2911c324c35cfbb27ed88688f8a119783105e912bceec8b", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 3578, + "endLine": 3601 + }, + "extractorTokens": [ + "State" + ], + "responseKind": "dynamic-json", + "responseTypeEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:3578-3601; field shape is implementation evidence only", + "evidenceLevel": "route+handler" + }, + { + "method": "GET", + "path": "/api/notifications/config", + "handler": "get_notification_config_handler", + "routeEvidence": "backend/src/main.rs:824-825", + "handlerEvidence": "backend/src/handlers.rs:3641-3652", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:3641-3652", + "responseSummary": "Rust return ( StatusCode, Json>, ); exact handler backend/src/handlers.rs:3641-3652", + "sha256": "96344b8b2d1b881879c78c33a954a6a7af4de9aa59ffc1d1ef77a4ebc980e277", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 3641, + "endLine": 3652 + }, + "extractorTokens": [ + "Json>", + "State>" + ], + "responseKind": "explicit-return", + "responseTypeEvidence": "Rust return ( StatusCode, Json>, ); exact handler backend/src/handlers.rs:3641-3652", + "brunoEvidence": [ + { + "file": "bruno-api/get_notification_config.bru", + "method": "GET", + "path": "/api/notifications/config", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "POST", + "path": "/api/notifications/config", + "handler": "set_notification_config_handler", + "routeEvidence": "backend/src/main.rs:824-826", + "handlerEvidence": "backend/src/handlers.rs:3655-3672", + "requestSummary": "body extractor crate::config::NotificationConfig fields [version:u8, channels:Vec, rules:Vec, log_cleanup:NotificationLogCleanupConfig] (config.rs:548); exact handler backend/src/handlers.rs:3655-3672", + "responseSummary": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:3655-3672", + "sha256": "5fc4fbfff68050e0887b051bb111a78b3d3805dfc32b685414f4178333285f3e", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 3655, + "endLine": 3672 + }, + "extractorTokens": [ + "Json", + "Json>", + "State>" + ], + "responseKind": "explicit-return", + "responseTypeEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:3655-3672", + "brunoEvidence": [ + { + "file": "bruno-api/set_notification_config.bru", + "method": "POST", + "path": "/api/notifications/config", + "bodyMode": "json", + "bodyFields": [ + "webhook", + "bark" + ] + } + ], + "evidenceLevel": "route+handler+model+bruno" + }, + { + "method": "POST", + "path": "/api/notifications/test/{channel}", + "handler": "test_notification_channel_handler", + "routeEvidence": "backend/src/main.rs:830-831", + "handlerEvidence": "backend/src/handlers.rs:3675-3704", + "requestSummary": "path extractor String; exact handler backend/src/handlers.rs:3675-3704", + "responseSummary": "Rust return ( StatusCode, Json>, ); exact handler backend/src/handlers.rs:3675-3704", + "sha256": "df28427afab1724aa28b0e28f39b7707778422f086b9ca9bd8ac8ec8e5c909e5", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 3675, + "endLine": 3704 + }, + "extractorTokens": [ + "Json>", + "Path", + "State>" + ], + "responseKind": "explicit-return", + "responseTypeEvidence": "Rust return ( StatusCode, Json>, ); exact handler backend/src/handlers.rs:3675-3704", + "evidenceLevel": "route+handler" + }, + { + "method": "GET", + "path": "/api/notifications/logs", + "handler": "get_notification_logs_handler", + "routeEvidence": "backend/src/main.rs:835-836", + "handlerEvidence": "backend/src/handlers.rs:3709-3734", + "requestSummary": "query extractor NotificationLogQuery fields [event_type:String, status:String, q:String, start_date:String, end_date:String, limit:i64, offset:i64] (handlers.rs:3607); exact handler backend/src/handlers.rs:3709-3734", + "responseSummary": "Rust return ( StatusCode, Json>, ); exact handler backend/src/handlers.rs:3709-3734", + "sha256": "da933f5e679cb9e14580b82b75a4caae71e26db4b2e9f57f32b99c1d472bf005", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 3709, + "endLine": 3734 + }, + "extractorTokens": [ + "Json>", + "Query", + "State>" + ], + "responseKind": "explicit-return", + "responseTypeEvidence": "Rust return ( StatusCode, Json>, ); exact handler backend/src/handlers.rs:3709-3734", + "evidenceLevel": "route+handler+model" + }, + { + "method": "POST", + "path": "/api/notifications/logs/clear", + "handler": "clear_notification_logs_handler", + "routeEvidence": "backend/src/main.rs:839-840", + "handlerEvidence": "backend/src/handlers.rs:3737-3760", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:3737-3760", + "responseSummary": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:3737-3760", + "sha256": "04de36a4268a3ab6f30354da995a73fb44689d28a2dcd4945548b3b1a89c64d3", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 3737, + "endLine": 3760 + }, + "extractorTokens": [ + "Json", + "Json>", + "State>" + ], + "responseKind": "explicit-return", + "responseTypeEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:3737-3760", + "evidenceLevel": "route+handler" + }, + { + "method": "GET", + "path": "/api/notifications/queue", + "handler": "get_notification_queue_handler", + "routeEvidence": "backend/src/main.rs:843-844", + "handlerEvidence": "backend/src/notification_queue.rs:24-41", + "requestSummary": "query extractor NotificationQueueQuery fields [limit:i64] (notification_queue.rs:14); exact handler backend/src/notification_queue.rs:24-41", + "responseSummary": "Rust return ( StatusCode, Json>, ); exact handler backend/src/notification_queue.rs:24-41", + "sha256": "babc88061bcb3386c8394818ccafe784c4c3fd32dd3626370faa0cf0f7652f86", + "handlerRange": { + "file": "backend/src/notification_queue.rs", + "startLine": 24, + "endLine": 41 + }, + "extractorTokens": [ + "Json>", + "Query", + "State>" + ], + "responseKind": "explicit-return", + "responseTypeEvidence": "Rust return ( StatusCode, Json>, ); exact handler backend/src/notification_queue.rs:24-41", + "evidenceLevel": "route+handler+model" + }, + { + "method": "POST", + "path": "/api/notifications/queue/retry-all", + "handler": "retry_all_notification_queue_handler", + "routeEvidence": "backend/src/main.rs:847-848", + "handlerEvidence": "backend/src/notification_queue.rs:84-100", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/notification_queue.rs:84-100", + "responseSummary": "Rust return (StatusCode, Json>); exact handler backend/src/notification_queue.rs:84-100", + "sha256": "f58e1be1cbf6a022663038bdbc76ec7c6df7c0bbcba6f13ea16faf25f03dcd34", + "handlerRange": { + "file": "backend/src/notification_queue.rs", + "startLine": 84, + "endLine": 100 + }, + "extractorTokens": [ + "Json>", + "State>" + ], + "responseKind": "explicit-return", + "responseTypeEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/notification_queue.rs:84-100", + "evidenceLevel": "route+handler" + }, + { + "method": "POST", + "path": "/api/notifications/queue/clear", + "handler": "clear_notification_queue_handler", + "routeEvidence": "backend/src/main.rs:851-852", + "handlerEvidence": "backend/src/notification_queue.rs:103-119", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/notification_queue.rs:103-119", + "responseSummary": "Rust return (StatusCode, Json>); exact handler backend/src/notification_queue.rs:103-119", + "sha256": "2501068f6741e02ed9dfb0475191acfa80ccc81f3cf9b8d2152920c78b7006f1", + "handlerRange": { + "file": "backend/src/notification_queue.rs", + "startLine": 103, + "endLine": 119 + }, + "extractorTokens": [ + "Json>", + "State>" + ], + "responseKind": "explicit-return", + "responseTypeEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/notification_queue.rs:103-119", + "evidenceLevel": "route+handler" + }, + { + "method": "DELETE", + "path": "/api/notifications/queue/{id}", + "handler": "delete_notification_queue_item_handler", + "routeEvidence": "backend/src/main.rs:855-856", + "handlerEvidence": "backend/src/notification_queue.rs:64-81", + "requestSummary": "path extractor i64; exact handler backend/src/notification_queue.rs:64-81", + "responseSummary": "Rust return (StatusCode, Json>); exact handler backend/src/notification_queue.rs:64-81", + "sha256": "6b845ad2da8f319d8cfa2cb208361901c5a3e852027d9cb96bd54bcd5b27b034", + "handlerRange": { + "file": "backend/src/notification_queue.rs", + "startLine": 64, + "endLine": 81 + }, + "extractorTokens": [ + "Json>", + "Path", + "State>" + ], + "responseKind": "explicit-return", + "responseTypeEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/notification_queue.rs:64-81", + "evidenceLevel": "route+handler" + }, + { + "method": "POST", + "path": "/api/notifications/queue/{id}/retry", + "handler": "retry_notification_queue_item_handler", + "routeEvidence": "backend/src/main.rs:859-860", + "handlerEvidence": "backend/src/notification_queue.rs:44-61", + "requestSummary": "path extractor i64; exact handler backend/src/notification_queue.rs:44-61", + "responseSummary": "Rust return (StatusCode, Json>); exact handler backend/src/notification_queue.rs:44-61", + "sha256": "068be5777b9d8f78a7d72c2368b788ab23e6ae1de4d4ab110596783be869d967", + "handlerRange": { + "file": "backend/src/notification_queue.rs", + "startLine": 44, + "endLine": 61 + }, + "extractorTokens": [ + "Json>", + "Path", + "State>" + ], + "responseKind": "explicit-return", + "responseTypeEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/notification_queue.rs:44-61", + "evidenceLevel": "route+handler" + }, + { + "method": "GET", + "path": "/api/automation/config", + "handler": "get_automation_config_handler", + "routeEvidence": "backend/src/main.rs:864-865", + "handlerEvidence": "backend/src/handlers.rs:3968-3979", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:3968-3979", + "responseSummary": "Rust return ( StatusCode, Json>, ); exact handler backend/src/handlers.rs:3968-3979", + "sha256": "eed75ffb9545cf5e08d2bc7ae01ead9830e9457842ce87613f8bdbad8d2b3bba", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 3968, + "endLine": 3979 + }, + "extractorTokens": [ + "Json>", + "State>" + ], + "responseKind": "explicit-return", + "responseTypeEvidence": "Rust return ( StatusCode, Json>, ); exact handler backend/src/handlers.rs:3968-3979", + "evidenceLevel": "route+handler" + }, + { + "method": "POST", + "path": "/api/automation/config", + "handler": "set_automation_config_handler", + "routeEvidence": "backend/src/main.rs:864-866", + "handlerEvidence": "backend/src/handlers.rs:3982-3999", + "requestSummary": "body extractor crate::config::AutomationConfig fields [enabled:bool, tasks:Vec] (config.rs:1394); exact handler backend/src/handlers.rs:3982-3999", + "responseSummary": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:3982-3999", + "sha256": "7f324660f7624a52d5521b4d2a15dbb099cf5a4c518b662a0f7d776f68a33c97", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 3982, + "endLine": 3999 + }, + "extractorTokens": [ + "Json", + "Json>", + "State>" + ], + "responseKind": "explicit-return", + "responseTypeEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:3982-3999", + "evidenceLevel": "route+handler+model" + }, + { + "method": "GET", + "path": "/api/automation/logs", + "handler": "get_automation_logs_handler", + "routeEvidence": "backend/src/main.rs:870-871", + "handlerEvidence": "backend/src/handlers.rs:4002-4027", + "requestSummary": "query extractor AutomationLogQuery fields [task_type:String, status:String, q:String, start_date:String, end_date:String, limit:i64, offset:i64] (handlers.rs:3938); exact handler backend/src/handlers.rs:4002-4027", + "responseSummary": "Rust return ( StatusCode, Json>, ); exact handler backend/src/handlers.rs:4002-4027", + "sha256": "b6cacc95a374e31ad4fb1141d8a1b002ddb6fb642b6300a501830744ad0a587d", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 4002, + "endLine": 4027 + }, + "extractorTokens": [ + "Json>", + "Query", + "State>" + ], + "responseKind": "explicit-return", + "responseTypeEvidence": "Rust return ( StatusCode, Json>, ); exact handler backend/src/handlers.rs:4002-4027", + "evidenceLevel": "route+handler+model" + }, + { + "method": "POST", + "path": "/api/automation/logs/clear", + "handler": "clear_automation_logs_handler", + "routeEvidence": "backend/src/main.rs:874-875", + "handlerEvidence": "backend/src/handlers.rs:4030-4053", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:4030-4053", + "responseSummary": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:4030-4053", + "sha256": "0912300e74c831ac108f2696eb28a65b5861cd508498330a9a91add6f6781d7e", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 4030, + "endLine": 4053 + }, + "extractorTokens": [ + "Json", + "Json>", + "State>" + ], + "responseKind": "explicit-return", + "responseTypeEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:4030-4053", + "evidenceLevel": "route+handler" + }, + { + "method": "POST", + "path": "/api/automation/test/{task_id}", + "handler": "test_automation_task_handler", + "routeEvidence": "backend/src/main.rs:878-879", + "handlerEvidence": "backend/src/handlers.rs:4056-4146", + "requestSummary": "path extractor String; exact handler backend/src/handlers.rs:4056-4146", + "responseSummary": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:4056-4146", + "sha256": "091db790a51ab1855cc1d96b0a07e5c1fc6570102050382abab2535ea5d2034e", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 4056, + "endLine": 4146 + }, + "extractorTokens": [ + "Json>", + "Path", + "State" + ], + "responseKind": "explicit-return", + "responseTypeEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/handlers.rs:4056-4146", + "evidenceLevel": "route+handler" + }, + { + "method": "GET", + "path": "/api/ota/status", + "handler": "get_ota_status_handler", + "routeEvidence": "backend/src/main.rs:882-883", + "handlerEvidence": "backend/src/handlers.rs:3763-3769", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:3763-3769", + "responseSummary": "dynamic-json construction in exact handler backend/src/handlers.rs:3763-3769; field shape is implementation evidence only", + "sha256": "89d8afa94cbad60c4845764679734bf5ef62b1bdf0f64f4e905740d978cce5b0", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 3763, + "endLine": 3769 + }, + "extractorTokens": [], + "responseKind": "dynamic-json", + "responseTypeEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:3763-3769; field shape is implementation evidence only", + "brunoEvidence": [ + { + "file": "bruno-api/get_ota_status.bru", + "method": "GET", + "path": "/api/ota/status", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "POST", + "path": "/api/ota/upload", + "handler": "upload_ota_handler", + "routeEvidence": "backend/src/main.rs:886-887", + "handlerEvidence": "backend/src/handlers.rs:3772-3792", + "requestSummary": "bytes extractor axum::body::Bytes; exact exact handler backend/src/handlers.rs:3772-3792", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:3772-3792", + "sha256": "becfb7dc7e9c1355976abcc802d12f03c06d05d035db2203516fee0cc0b12a74", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 3772, + "endLine": 3792 + }, + "extractorTokens": [ + "axum::body::Bytes" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:3772-3792", + "evidenceLevel": "route+handler" + }, + { + "method": "POST", + "path": "/api/ota/latest-release", + "handler": "get_latest_ota_release_handler", + "routeEvidence": "backend/src/main.rs:892-893", + "handlerEvidence": "backend/src/handlers.rs:3795-3825", + "requestSummary": "body extractor crate::models::OtaOnlinePrepareRequest fields [proxy_prefix:Option] (models.rs:986); exact handler backend/src/handlers.rs:3795-3825", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:3795-3825", + "sha256": "61083109860620e593336906b5b08360b7694f3553ab6859445166a52cef0314", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 3795, + "endLine": 3825 + }, + "extractorTokens": [ + "Json" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:3795-3825", + "evidenceLevel": "route+handler+model" + }, + { + "method": "POST", + "path": "/api/ota/online-prepare", + "handler": "prepare_online_ota_handler", + "routeEvidence": "backend/src/main.rs:896-897", + "handlerEvidence": "backend/src/handlers.rs:3828-3889", + "requestSummary": "body extractor crate::models::OtaOnlinePrepareRequest fields [proxy_prefix:Option] (models.rs:986); exact handler backend/src/handlers.rs:3828-3889", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:3828-3889", + "sha256": "6d70f80714b7f00461ba8eb4a3dc5c7f6056590bed50162031e7c0af8e5371e2", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 3828, + "endLine": 3889 + }, + "extractorTokens": [ + "Json" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:3828-3889", + "evidenceLevel": "route+handler+model" + }, + { + "method": "POST", + "path": "/api/ota/apply", + "handler": "apply_ota_handler", + "routeEvidence": "backend/src/main.rs:900-901", + "handlerEvidence": "backend/src/handlers.rs:3892-3911", + "requestSummary": "body extractor crate::models::OtaApplyRequest fields [restart_now:bool] (models.rs:1030); exact handler backend/src/handlers.rs:3892-3911", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:3892-3911", + "sha256": "46c24f90fdb71e52316e32c93e6c37265d79dc4bb2076bbed9c97b92517c3d03", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 3892, + "endLine": 3911 + }, + "extractorTokens": [ + "Json" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:3892-3911", + "brunoEvidence": [ + { + "file": "bruno-api/post_ota_apply_restart.bru", + "method": "POST", + "path": "/api/ota/apply", + "bodyMode": "json", + "bodyFields": [ + "restart_now" + ] + }, + { + "file": "bruno-api/post_ota_apply.bru", + "method": "POST", + "path": "/api/ota/apply", + "bodyMode": "json", + "bodyFields": [ + "restart_now" + ] + } + ], + "evidenceLevel": "route+handler+model+bruno" + }, + { + "method": "POST", + "path": "/api/ota/cancel", + "handler": "cancel_ota_handler", + "routeEvidence": "backend/src/main.rs:904-905", + "handlerEvidence": "backend/src/handlers.rs:3914-3931", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:3914-3931", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:3914-3931", + "sha256": "9085b81dc3f8588654d7b258de241d29832afa2c40a9390bbbe9245c069a1603", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 3914, + "endLine": 3931 + }, + "extractorTokens": [], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/handlers.rs:3914-3931", + "brunoEvidence": [ + { + "file": "bruno-api/post_ota_cancel.bru", + "method": "POST", + "path": "/api/ota/cancel", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "POST", + "path": "/api/auth/password", + "handler": "auth::change_password", + "routeEvidence": "backend/src/main.rs:908-909", + "handlerEvidence": "backend/src/auth.rs:491-553", + "requestSummary": "body extractor ChangePasswordRequest fields [new_password:String] (auth.rs:59); exact handler backend/src/auth.rs:491-553", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/auth.rs:491-553", + "sha256": "ec7372792586fd2ab24eda6fc2fc008b04c567045c6dfc54f9ae24530ce3b113", + "handlerRange": { + "file": "backend/src/auth.rs", + "startLine": 491, + "endLine": 553 + }, + "extractorTokens": [ + "Json", + "State" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/auth.rs:491-553", + "evidenceLevel": "route+handler+model" + }, + { + "method": "GET", + "path": "/api/auth/settings", + "handler": "auth::get_settings", + "routeEvidence": "backend/src/main.rs:912-913", + "handlerEvidence": "backend/src/auth.rs:555-570", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/auth.rs:555-570", + "responseSummary": "Rust return (StatusCode, Json>); exact handler backend/src/auth.rs:555-570", + "sha256": "035bdb5afbefdcb84d93a6f58f94ace658cfd041bea5ac3f41f1d6d32d4eca1f", + "handlerRange": { + "file": "backend/src/auth.rs", + "startLine": 555, + "endLine": 570 + }, + "extractorTokens": [ + "Json>", + "State" + ], + "responseKind": "explicit-return", + "responseTypeEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/auth.rs:555-570", + "evidenceLevel": "route+handler" + }, + { + "method": "POST", + "path": "/api/auth/settings", + "handler": "auth::set_settings", + "routeEvidence": "backend/src/main.rs:912-914", + "handlerEvidence": "backend/src/auth.rs:572-640", + "requestSummary": "body extractor SecurityConfig fields [password_protection_enabled:bool, password_min_length:u8, password_require_letters:bool, password_require_digits:bool, password_require_symbols:bool, session_ttl_seconds:i64, idle_timeout_seconds:i64] (config.rs:615); exact handler backend/src/auth.rs:572-640", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/auth.rs:572-640", + "sha256": "10b564c9437defd56e62bb46e622cce512d3cd42ad8fee67699697a7828987e8", + "handlerRange": { + "file": "backend/src/auth.rs", + "startLine": 572, + "endLine": 640 + }, + "extractorTokens": [ + "Json", + "State", + "HeaderMap" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/auth.rs:572-640", + "evidenceLevel": "route+handler+model" + }, + { + "method": "GET", + "path": "/api/health", + "handler": "health_check", + "routeEvidence": "backend/src/main.rs:923-923", + "handlerEvidence": "backend/src/handlers.rs:65-75", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/handlers.rs:65-75", + "responseSummary": "dynamic-json construction in exact handler backend/src/handlers.rs:65-75; field shape is implementation evidence only", + "sha256": "e169391fd5f376b8602c2164d0070744964b009f367d2f42c8832b9f98faad25", + "handlerRange": { + "file": "backend/src/handlers.rs", + "startLine": 65, + "endLine": 75 + }, + "extractorTokens": [], + "responseKind": "dynamic-json", + "responseTypeEvidence": "dynamic-json construction in exact handler backend/src/handlers.rs:65-75; field shape is implementation evidence only", + "brunoEvidence": [ + { + "file": "bruno-api/get_health.bru", + "method": "GET", + "path": "/api/health", + "bodyMode": "none", + "bodyFields": [] + } + ], + "evidenceLevel": "route+handler+bruno" + }, + { + "method": "GET", + "path": "/api/auth/status", + "handler": "auth::status", + "routeEvidence": "backend/src/main.rs:925-926", + "handlerEvidence": "backend/src/auth.rs:345-364", + "requestSummary": "none; no body/query/path extractor; exact handler backend/src/auth.rs:345-364", + "responseSummary": "Rust return (StatusCode, Json>); exact handler backend/src/auth.rs:345-364", + "sha256": "0ee683548f9f803687e7162cd5c596b09e36ff0adad03a5cb63c2067ece24aa3", + "handlerRange": { + "file": "backend/src/auth.rs", + "startLine": 345, + "endLine": 364 + }, + "extractorTokens": [ + "Json>", + "State", + "HeaderMap" + ], + "responseKind": "explicit-return", + "responseTypeEvidence": "Rust return (StatusCode, Json>); exact handler backend/src/auth.rs:345-364", + "evidenceLevel": "route+handler" + }, + { + "method": "POST", + "path": "/api/auth/setup", + "handler": "auth::setup", + "routeEvidence": "backend/src/main.rs:929-930", + "handlerEvidence": "backend/src/auth.rs:366-427", + "requestSummary": "body extractor LoginRequest fields [password:String] (auth.rs:54); exact handler backend/src/auth.rs:366-427", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/auth.rs:366-427", + "sha256": "1c0c5eb2cc77326de57062361dfe9c499aad351d8d3bbd101844c5cd0be43a6f", + "handlerRange": { + "file": "backend/src/auth.rs", + "startLine": 366, + "endLine": 427 + }, + "extractorTokens": [ + "Json", + "State" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/auth.rs:366-427", + "evidenceLevel": "route+handler+model" + }, + { + "method": "POST", + "path": "/api/auth/login", + "handler": "auth::login", + "routeEvidence": "backend/src/main.rs:933-934", + "handlerEvidence": "backend/src/auth.rs:429-489", + "requestSummary": "body extractor LoginRequest fields [password:String] (auth.rs:54); exact handler backend/src/auth.rs:429-489", + "responseSummary": "success/error payload evidenced by ApiResponse; exact handler backend/src/auth.rs:429-489", + "sha256": "defd9444410e5b124ab0d621e7faf70bae335363e7e287738273e9e06cc151a1", + "handlerRange": { + "file": "backend/src/auth.rs", + "startLine": 429, + "endLine": 489 + }, + "extractorTokens": [ + "Json", + "State" + ], + "responseKind": "api-response-payload", + "responseTypeEvidence": "success/error payload evidenced by ApiResponse; exact handler backend/src/auth.rs:429-489", + "evidenceLevel": "route+handler+model" + }, + { + "method": "POST", + "path": "/api/auth/logout", + "handler": "auth::logout", + "routeEvidence": "backend/src/main.rs:937-938", + "handlerEvidence": "backend/src/auth.rs:642-655", + "requestSummary": "none body; HeaderMap carries simadmin_session cookie; exact handler backend/src/auth.rs:642-655; cookie name backend/src/auth.rs:43-43", + "responseSummary": "dynamic-json construction in exact handler backend/src/auth.rs:642-655; field shape is implementation evidence only", + "sha256": "1ccf5c295251b2a22462cc81c3759330b65979cef524facbf2998b712c5daee1", + "handlerRange": { + "file": "backend/src/auth.rs", + "startLine": 642, + "endLine": 655 + }, + "extractorTokens": [ + "State", + "HeaderMap" + ], + "responseKind": "dynamic-json", + "responseTypeEvidence": "dynamic-json construction in exact handler backend/src/auth.rs:642-655; field shape is implementation evidence only", + "evidenceLevel": "route+handler" + } + ], + "snapshotManifest": "upstream-58e2204/manifest.json", + "opaqueIntoResponseCount": 0, + "brunoOperationCount": 67, + "brunoFileCount": 82 +} diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/backend/src/auth.rs b/packages/operation-registry/test/fixtures/upstream-58e2204/backend/src/auth.rs new file mode 100644 index 0000000..271ae10 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/backend/src/auth.rs @@ -0,0 +1,699 @@ +//! Single-admin authentication for the SimAdmin web console. + +use std::io::{self, Write}; +use std::num::NonZeroU32; + +use anyhow::{bail, Result}; +use axum::{ + extract::State, + http::{header, HeaderMap, HeaderValue, Method, StatusCode}, + middleware::Next, + response::{IntoResponse, Response}, + Json, +}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use ring::{ + digest, pbkdf2, + rand::{SecureRandom, SystemRandom}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::{ + config::SecurityConfig, + db::Database, + models::ApiResponse, + state::AppState, + system_event::{ + codes as system_event_codes, severity as system_event_severity, + status as system_event_status, + }, +}; + +const PASSWORD_KEY: &str = "admin_password_hash"; +const PASSWORD_ALGORITHM: &str = "pbkdf2_sha256"; +const PBKDF2_ITERATIONS: u32 = 210_000; +const PASSWORD_SALT_LEN: usize = 16; +const PASSWORD_HASH_LEN: usize = 32; +const PASSWORD_MAX_LENGTH: usize = 64; +const PASSWORD_MIN_LENGTH_MIN: u8 = 1; +const PASSWORD_MIN_LENGTH_MAX: u8 = PASSWORD_MAX_LENGTH as u8; +const SESSION_TOKEN_LEN: usize = 32; +const SESSION_TTL_NEVER_SECONDS: i64 = 100 * 365 * 24 * 60 * 60; +const SESSION_COOKIE: &str = "simadmin_session"; +const SESSION_TTL_OPTIONS: [i64; 5] = [ + 24 * 60 * 60, + 7 * 24 * 60 * 60, + 14 * 24 * 60 * 60, + 30 * 24 * 60 * 60, + -1, +]; +const IDLE_TIMEOUT_OPTIONS: [i64; 6] = [30 * 60, 60 * 60, 2 * 60 * 60, 3 * 60 * 60, 6 * 60 * 60, 0]; + +#[derive(Debug, Deserialize)] +pub struct LoginRequest { + pub password: String, +} + +#[derive(Debug, Deserialize)] +pub struct ChangePasswordRequest { + pub new_password: String, +} + +#[derive(Debug, Serialize)] +pub struct AuthStatusResponse { + pub configured: bool, + pub authenticated: bool, + pub settings: SecurityConfig, +} + +#[derive(Debug, Serialize)] +pub struct AuthSettingsResponse { + pub configured: bool, + pub settings: SecurityConfig, +} + +#[derive(Debug)] +struct SessionToken { + token: String, + hash: String, +} + +fn normalize_security_settings(mut settings: SecurityConfig) -> SecurityConfig { + if !(PASSWORD_MIN_LENGTH_MIN..=PASSWORD_MIN_LENGTH_MAX).contains(&settings.password_min_length) + { + settings.password_min_length = SecurityConfig::default().password_min_length; + } + if !SESSION_TTL_OPTIONS.contains(&settings.session_ttl_seconds) { + settings.session_ttl_seconds = SecurityConfig::default().session_ttl_seconds; + } + if !IDLE_TIMEOUT_OPTIONS.contains(&settings.idle_timeout_seconds) { + settings.idle_timeout_seconds = SecurityConfig::default().idle_timeout_seconds; + } + if !settings.password_require_letters + && !settings.password_require_digits + && !settings.password_require_symbols + { + settings.password_require_letters = true; + } + settings +} + +fn validate_security_settings(settings: &SecurityConfig) -> Result<()> { + if !(PASSWORD_MIN_LENGTH_MIN..=PASSWORD_MIN_LENGTH_MAX).contains(&settings.password_min_length) + { + bail!("密码最小长度需为 1-64 之间的整数"); + } + if !settings.password_require_letters + && !settings.password_require_digits + && !settings.password_require_symbols + { + bail!("字符类型要求至少需要选择一项"); + } + if !SESSION_TTL_OPTIONS.contains(&settings.session_ttl_seconds) { + bail!("会话有效期只能选择 1 天、7 天、14 天、30 天或永不过期"); + } + if !IDLE_TIMEOUT_OPTIONS.contains(&settings.idle_timeout_seconds) { + bail!("空闲超时只能选择 30 分钟、1 小时、2 小时、3 小时、6 小时或关闭"); + } + Ok(()) +} + +fn configured_session_ttl_seconds(settings: &SecurityConfig) -> i64 { + if settings.session_ttl_seconds < 0 { + SESSION_TTL_NEVER_SECONDS + } else { + settings.session_ttl_seconds + } +} + +fn enabled_password_types_text(settings: &SecurityConfig) -> &'static str { + match ( + settings.password_require_letters, + settings.password_require_digits, + settings.password_require_symbols, + ) { + (true, true, true) => "英文字母、数字和符号", + (true, true, false) => "英文字母和数字", + (true, false, true) => "英文字母和符号", + (false, true, true) => "数字和符号", + (true, false, false) => "英文字母", + (false, true, false) => "数字", + (false, false, true) => "符号", + (false, false, false) => "英文字母、数字和符号", + } +} + +fn password_byte_allowed(byte: u8, settings: &SecurityConfig) -> bool { + byte.is_ascii_graphic() + && ((settings.password_require_letters && byte.is_ascii_alphabetic()) + || (settings.password_require_digits && byte.is_ascii_digit()) + || (settings.password_require_symbols + && byte.is_ascii_graphic() + && !byte.is_ascii_alphanumeric())) +} + +pub fn validate_admin_password(password: &str, settings: &SecurityConfig) -> Result<()> { + let settings = normalize_security_settings(settings.clone()); + if !password + .bytes() + .all(|byte| password_byte_allowed(byte, &settings)) + { + bail!( + "密码只能包含{},不能包含空格、中文或未启用的字符类型", + enabled_password_types_text(&settings) + ); + } + if !((settings.password_min_length as usize)..=PASSWORD_MAX_LENGTH).contains(&password.len()) { + bail!( + "密码长度需为 {}-{} 个字符", + settings.password_min_length, + PASSWORD_MAX_LENGTH + ); + } + + if settings.password_require_letters && !password.bytes().any(|byte| byte.is_ascii_alphabetic()) + { + bail!("密码需包含英文字母"); + } + if settings.password_require_digits && !password.bytes().any(|byte| byte.is_ascii_digit()) { + bail!("密码需包含数字"); + } + if settings.password_require_symbols + && !password + .bytes() + .any(|byte| byte.is_ascii_graphic() && !byte.is_ascii_alphanumeric()) + { + bail!("密码需包含符号"); + } + Ok(()) +} + +pub fn hash_password(password: &str, settings: &SecurityConfig) -> Result { + validate_admin_password(password, settings)?; + + let rng = SystemRandom::new(); + let mut salt = [0u8; PASSWORD_SALT_LEN]; + rng.fill(&mut salt) + .map_err(|_| anyhow::anyhow!("Failed to generate password salt"))?; + + let mut output = [0u8; PASSWORD_HASH_LEN]; + let iterations = NonZeroU32::new(PBKDF2_ITERATIONS).expect("non-zero iterations"); + pbkdf2::derive( + pbkdf2::PBKDF2_HMAC_SHA256, + iterations, + &salt, + password.as_bytes(), + &mut output, + ); + + Ok(format!( + "{}${}${}${}", + PASSWORD_ALGORITHM, + PBKDF2_ITERATIONS, + URL_SAFE_NO_PAD.encode(salt), + URL_SAFE_NO_PAD.encode(output) + )) +} + +fn verify_password(password: &str, encoded_hash: &str) -> Result { + let parts: Vec<&str> = encoded_hash.split('$').collect(); + if parts.len() != 4 || parts[0] != PASSWORD_ALGORITHM { + bail!("Unsupported password hash format"); + } + + let iterations = parts[1].parse::()?; + let iterations = NonZeroU32::new(iterations).ok_or_else(|| anyhow::anyhow!("Invalid hash"))?; + let salt = URL_SAFE_NO_PAD.decode(parts[2])?; + let expected = URL_SAFE_NO_PAD.decode(parts[3])?; + + Ok(pbkdf2::verify( + pbkdf2::PBKDF2_HMAC_SHA256, + iterations, + &salt, + password.as_bytes(), + &expected, + ) + .is_ok()) +} + +fn generate_session_token() -> Result { + let rng = SystemRandom::new(); + let mut raw = [0u8; SESSION_TOKEN_LEN]; + rng.fill(&mut raw) + .map_err(|_| anyhow::anyhow!("Failed to generate session token"))?; + let token = URL_SAFE_NO_PAD.encode(raw); + let hash = hash_session_token(&token); + Ok(SessionToken { token, hash }) +} + +fn hash_session_token(token: &str) -> String { + URL_SAFE_NO_PAD.encode(digest::digest(&digest::SHA256, token.as_bytes()).as_ref()) +} + +fn session_cookie(token: &str, settings: &SecurityConfig) -> String { + let max_age = configured_session_ttl_seconds(settings); + format!("{SESSION_COOKIE}={token}; HttpOnly; SameSite=Lax; Path=/; Max-Age={max_age}") +} + +fn expired_session_cookie() -> String { + format!("{SESSION_COOKIE}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0") +} + +fn cookie_token(headers: &HeaderMap) -> Option { + let cookie = headers.get(header::COOKIE)?.to_str().ok()?; + cookie.split(';').find_map(|part| { + let (name, value) = part.trim().split_once('=')?; + (name == SESSION_COOKIE).then(|| value.to_string()) + }) +} + +fn wants_login_redirect(headers: &HeaderMap) -> bool { + let accepts_html = headers + .get(header::ACCEPT) + .and_then(|value| value.to_str().ok()) + .map(|value| value.contains("text/html")) + .unwrap_or(false); + let is_navigation = headers + .get("sec-fetch-mode") + .and_then(|value| value.to_str().ok()) + .map(|value| value.eq_ignore_ascii_case("navigate")) + .unwrap_or(false); + accepts_html || is_navigation +} + +fn unauthorized_response(headers: &HeaderMap, message: impl Into) -> Response { + if wants_login_redirect(headers) { + return (StatusCode::SEE_OTHER, [(header::LOCATION, "/login")]).into_response(); + } + + ( + StatusCode::UNAUTHORIZED, + Json(ApiResponse::::error(message.into())), + ) + .into_response() +} + +fn response_with_session( + payload: ApiResponse, + token: &str, + settings: &SecurityConfig, +) -> Response { + let mut response = Json(payload).into_response(); + let cookie = session_cookie(token, settings); + if let Ok(value) = HeaderValue::from_str(&cookie) { + response.headers_mut().insert(header::SET_COOKIE, value); + } + response +} + +fn is_authenticated(database: &Database, headers: &HeaderMap) -> bool { + let Some(token) = cookie_token(headers) else { + return false; + }; + database + .auth_session_valid(&hash_session_token(&token)) + .unwrap_or(false) +} + +pub async fn auth_middleware( + State(state): State, + headers: HeaderMap, + request: axum::extract::Request, + next: Next, +) -> Response { + if request.method() == Method::OPTIONS { + return next.run(request).await; + } + + let settings = normalize_security_settings(state.config_manager.get_security()); + if !settings.password_protection_enabled { + return next.run(request).await; + } + + if !state.database.auth_is_configured().unwrap_or(false) { + return unauthorized_response(&headers, "管理员密码尚未设置"); + } + + if !is_authenticated(&state.database, &headers) { + return unauthorized_response(&headers, "请先登录"); + } + + next.run(request).await +} + +pub async fn status( + State(state): State, + headers: HeaderMap, +) -> (StatusCode, Json>) { + let settings = normalize_security_settings(state.config_manager.get_security()); + let configured = state.database.auth_is_configured().unwrap_or(false); + let authenticated = !settings.password_protection_enabled + || (configured && is_authenticated(&state.database, &headers)); + ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Success", + AuthStatusResponse { + configured, + authenticated, + settings, + }, + )), + ) +} + +pub async fn setup(State(state): State, Json(payload): Json) -> Response { + let settings = normalize_security_settings(state.config_manager.get_security()); + if state.database.auth_is_configured().unwrap_or(false) { + return ( + StatusCode::BAD_REQUEST, + Json(ApiResponse::::error("管理员密码已设置")), + ) + .into_response(); + } + + let password_hash = match hash_password(&payload.password, &settings) { + Ok(hash) => hash, + Err(err) => { + return ( + StatusCode::BAD_REQUEST, + Json(ApiResponse::::error(err.to_string())), + ) + .into_response() + } + }; + + if let Err(err) = state + .database + .set_auth_config_value(PASSWORD_KEY, &password_hash) + { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiResponse::::error(format!( + "保存管理员密码失败: {err}" + ))), + ) + .into_response(); + } + + let session = match generate_session_token() { + Ok(session) => session, + Err(err) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiResponse::::error(err.to_string())), + ) + .into_response() + } + }; + + if let Err(err) = state + .database + .insert_auth_session(&session.hash, configured_session_ttl_seconds(&settings)) + { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiResponse::::error(format!("创建会话失败: {err}"))), + ) + .into_response(); + } + + response_with_session( + ApiResponse::success_with_message("Admin password configured", Value::Null), + &session.token, + &settings, + ) +} + +pub async fn login(State(state): State, Json(payload): Json) -> Response { + let settings = normalize_security_settings(state.config_manager.get_security()); + let Some(password_hash) = state + .database + .get_auth_config_value(PASSWORD_KEY) + .unwrap_or(None) + else { + return ( + StatusCode::BAD_REQUEST, + Json(ApiResponse::::error("管理员密码尚未设置")), + ) + .into_response(); + }; + + match verify_password(&payload.password, &password_hash) { + Ok(true) => {} + Ok(false) => { + state.system_event_emitter.record_login_failure().await; + return ( + StatusCode::UNAUTHORIZED, + Json(ApiResponse::::error("管理员密码不正确")), + ) + .into_response(); + } + Err(err) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiResponse::::error(format!("验证密码失败: {err}"))), + ) + .into_response() + } + } + + let session = match generate_session_token() { + Ok(session) => session, + Err(err) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiResponse::::error(err.to_string())), + ) + .into_response() + } + }; + + if let Err(err) = state + .database + .insert_auth_session(&session.hash, configured_session_ttl_seconds(&settings)) + { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiResponse::::error(format!("创建会话失败: {err}"))), + ) + .into_response(); + } + + response_with_session( + ApiResponse::success_with_message("Logged in", Value::Null), + &session.token, + &settings, + ) +} + +pub async fn change_password( + State(state): State, + Json(payload): Json, +) -> Response { + let settings = normalize_security_settings(state.config_manager.get_security()); + let new_hash = match hash_password(&payload.new_password, &settings) { + Ok(hash) => hash, + Err(err) => { + return ( + StatusCode::BAD_REQUEST, + Json(ApiResponse::::error(err.to_string())), + ) + .into_response() + } + }; + + if let Err(err) = state.database.replace_admin_password_hash(&new_hash) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiResponse::::error(format!("更新密码失败: {err}"))), + ) + .into_response(); + } + + let session = match generate_session_token() { + Ok(session) => session, + Err(err) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiResponse::::error(err.to_string())), + ) + .into_response() + } + }; + + if let Err(err) = state + .database + .insert_auth_session(&session.hash, configured_session_ttl_seconds(&settings)) + { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiResponse::::error(format!("创建会话失败: {err}"))), + ) + .into_response(); + } + + state + .system_event_emitter + .emit_code( + system_event_codes::SECURITY_PASSWORD_CHANGED, + system_event_severity::WARNING, + system_event_status::CHANGED, + "admin", + "管理员密码已修改", + ) + .await; + + response_with_session( + ApiResponse::success_with_message("Password updated", Value::Null), + &session.token, + &settings, + ) +} + +pub async fn get_settings( + State(state): State, +) -> (StatusCode, Json>) { + let settings = normalize_security_settings(state.config_manager.get_security()); + let configured = state.database.auth_is_configured().unwrap_or(false); + ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Success", + AuthSettingsResponse { + configured, + settings, + }, + )), + ) +} + +pub async fn set_settings( + State(state): State, + headers: HeaderMap, + Json(payload): Json, +) -> Response { + let previous_settings = normalize_security_settings(state.config_manager.get_security()); + if let Err(err) = validate_security_settings(&payload) { + return ( + StatusCode::BAD_REQUEST, + Json(ApiResponse::::error(err.to_string())), + ) + .into_response(); + } + let settings = normalize_security_settings(payload); + if let Err(err) = state.config_manager.set_security(settings.clone()) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiResponse::::error(format!( + "保存安全设置失败: {err}" + ))), + ) + .into_response(); + } + + if previous_settings.password_protection_enabled && !settings.password_protection_enabled { + state + .system_event_emitter + .emit_code( + system_event_codes::SECURITY_PASSWORD_PROTECTION_DISABLED, + system_event_severity::CRITICAL, + system_event_status::CHANGED, + "security", + "密码保护已关闭", + ) + .await; + } else if previous_settings != settings { + state + .system_event_emitter + .emit_code( + system_event_codes::SECURITY_POLICY_CHANGED, + system_event_severity::WARNING, + system_event_status::CHANGED, + "security", + "安全策略已变更", + ) + .await; + } + + let mut response = ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Security settings saved", + settings.clone(), + )), + ) + .into_response(); + + if let Some(token) = cookie_token(&headers) { + let _ = state.database.refresh_auth_session( + &hash_session_token(&token), + configured_session_ttl_seconds(&settings), + ); + if let Ok(value) = HeaderValue::from_str(&session_cookie(&token, &settings)) { + response.headers_mut().insert(header::SET_COOKIE, value); + } + } + + response +} + +pub async fn logout(State(state): State, headers: HeaderMap) -> Response { + if let Some(token) = cookie_token(&headers) { + let _ = state + .database + .delete_auth_session(&hash_session_token(&token)); + } + + let mut response = + Json(ApiResponse::success_with_message("Logged out", Value::Null)).into_response(); + if let Ok(value) = HeaderValue::from_str(&expired_session_cookie()) { + response.headers_mut().insert(header::SET_COOKIE, value); + } + response +} + +pub fn reset_admin_password_interactive( + database: &Database, + settings: &SecurityConfig, +) -> Result<()> { + let password = read_password_line("New admin password: ")?; + let confirm = read_password_line("Confirm admin password: ")?; + if password != confirm { + bail!("Passwords do not match"); + } + let hash = hash_password(&password, settings)?; + database.replace_admin_password_hash(&hash)?; + println!("Admin password updated and all web sessions were cleared."); + Ok(()) +} + +pub fn clear_admin_auth(database: &Database) -> Result<()> { + database.clear_admin_auth()?; + println!("Admin password and all web sessions were cleared."); + println!("Open the web UI to set a new admin password."); + Ok(()) +} + +#[cfg(unix)] +fn read_password_line(prompt: &str) -> Result { + print!("{prompt}"); + io::stdout().flush()?; + let _ = std::process::Command::new("stty").arg("-echo").status(); + let mut value = String::new(); + let result = io::stdin().read_line(&mut value); + let _ = std::process::Command::new("stty").arg("echo").status(); + println!(); + result?; + Ok(value.trim_end_matches(['\r', '\n']).to_string()) +} + +#[cfg(not(unix))] +fn read_password_line(prompt: &str) -> Result { + print!("{prompt}"); + io::stdout().flush()?; + let mut value = String::new(); + io::stdin().read_line(&mut value)?; + Ok(value.trim_end_matches(['\r', '\n']).to_string()) +} diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/backend/src/config.rs b/packages/operation-registry/test/fixtures/upstream-58e2204/backend/src/config.rs new file mode 100644 index 0000000..1effb4f --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/backend/src/config.rs @@ -0,0 +1,2067 @@ +//! 配置管理模块 +//! +//! 使用 JSON 文件存储用户配置,支持热更新 + +use serde::{de::Error as DeError, Deserialize, Deserializer, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::sync::{Arc, RwLock}; +use tracing::{info, warn}; + +use crate::models::WorkMode; + +/// Webhook 配置 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WebhookConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub url: String, + #[serde(default = "default_true")] + pub forward_sms: bool, + #[serde(default = "default_true")] + pub forward_calls: bool, + #[serde(default = "default_true")] + pub forward_ddns: bool, + #[serde(default = "default_true")] + pub forward_updates: bool, + #[serde(default)] + pub headers: HashMap, + #[serde(default)] + pub secret: String, // 可选的签名密钥 + #[serde(default = "default_sms_template")] + pub sms_template: String, // 短信 payload 模板 + #[serde(default = "default_call_template")] + pub call_template: String, // 通话 payload 模板 + #[serde(default = "default_ddns_template")] + pub ddns_template: String, // DDNS payload 模板 + #[serde(default = "default_update_template")] + pub update_template: String, // 版本更新 payload 模板 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MessageChannelConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_true")] + pub forward_sms: bool, + #[serde(default = "default_true")] + pub forward_calls: bool, + #[serde(default = "default_true")] + pub forward_ddns: bool, + #[serde(default = "default_true")] + pub forward_updates: bool, + #[serde(default = "default_plain_sms_template")] + pub sms_template: String, + #[serde(default = "default_plain_call_template")] + pub call_template: String, + #[serde(default = "default_plain_ddns_template")] + pub ddns_template: String, + #[serde(default = "default_plain_update_template")] + pub update_template: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BarkConfig { + #[serde(flatten)] + pub common: MessageChannelConfig, + #[serde(default = "default_bark_server_url")] + pub server_url: String, + #[serde(default)] + pub device_key: String, + #[serde(default = "default_sms_title_template")] + pub title_template: String, + #[serde(default)] + pub group: String, + #[serde(default)] + pub sound: String, + #[serde(default)] + pub level: String, + #[serde(default)] + pub icon: String, + #[serde(default)] + pub click_url: String, + #[serde(default)] + pub copy: String, + #[serde(default)] + pub auto_copy: bool, + #[serde(default = "default_true")] + pub save_history: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PushPlusConfig { + #[serde(flatten)] + pub common: MessageChannelConfig, + #[serde(default)] + pub token: String, + #[serde(default = "default_sms_title_template")] + pub title_template: String, + #[serde(default)] + pub topic: String, + #[serde(default = "default_pushplus_template")] + pub template: String, + #[serde(default)] + pub channel: String, + #[serde(default)] + pub option: String, + #[serde(default)] + pub callback_url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WecomAppConfig { + #[serde(flatten)] + pub common: MessageChannelConfig, + #[serde(default)] + pub corp_id: String, + #[serde(default)] + pub agent_id: String, + #[serde(default)] + pub secret: String, + #[serde(default = "default_wecom_to_user")] + pub to_user: String, + #[serde(default)] + pub to_party: String, + #[serde(default)] + pub to_tag: String, + #[serde(default)] + pub safe: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WecomRobotConfig { + #[serde(flatten)] + pub common: MessageChannelConfig, + #[serde(default)] + pub webhook_url: String, + #[serde(default)] + pub key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DingtalkRobotConfig { + #[serde(flatten)] + pub common: MessageChannelConfig, + #[serde(default)] + pub webhook_url: String, + #[serde(default)] + pub access_token: String, + #[serde(default)] + pub secret: String, + #[serde(default)] + pub at_mobiles: String, + #[serde(default)] + pub at_all: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DingtalkAppConfig { + #[serde(flatten)] + pub common: MessageChannelConfig, + #[serde(default)] + pub app_key: String, + #[serde(default)] + pub app_secret: String, + #[serde(default)] + pub robot_code: String, + #[serde(default)] + pub open_conversation_id: String, + #[serde(default = "default_dingtalk_msg_key")] + pub msg_key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FeishuRobotConfig { + #[serde(flatten)] + pub common: MessageChannelConfig, + #[serde(default)] + pub webhook_url: String, + #[serde(default)] + pub token: String, + #[serde(default)] + pub secret: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TelegramConfig { + #[serde(flatten)] + pub common: MessageChannelConfig, + #[serde(default)] + pub bot_token: String, + #[serde(default)] + pub chat_id: String, + #[serde(default)] + pub parse_mode: String, + #[serde(default)] + pub disable_web_page_preview: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerChan3Config { + #[serde(flatten)] + pub common: MessageChannelConfig, + #[serde(default)] + pub send_key: String, + #[serde(default)] + pub uid: String, + #[serde(default)] + pub channel: String, + #[serde(default)] + pub openid: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EmailConfig { + #[serde(flatten)] + pub common: MessageChannelConfig, + #[serde(default)] + pub smtp_host: String, + #[serde(default = "default_smtp_port")] + pub smtp_port: u16, + #[serde(default = "default_smtp_security")] + pub smtp_security: String, + #[serde(default, alias = "allow_insecure_connections")] + pub allow_insecure_tls: bool, + #[serde(default)] + pub username: String, + #[serde(default)] + pub password: String, + #[serde(default)] + pub sender_address: String, + #[serde(default)] + pub sender_name: String, + #[serde(default, alias = "receiver_address")] + pub receiver_addresses: String, + #[serde(default = "default_email_message_format")] + pub message_format: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LegacyNotificationConfig { + #[serde(default)] + pub webhook: WebhookConfig, + #[serde(default)] + pub bark: BarkConfig, + #[serde(default)] + pub pushplus: PushPlusConfig, + #[serde(default)] + pub wecom_app: WecomAppConfig, + #[serde(default)] + pub wecom_robot: WecomRobotConfig, + #[serde(default)] + pub dingtalk_robot: DingtalkRobotConfig, + #[serde(default)] + pub dingtalk_app: DingtalkAppConfig, + #[serde(default)] + pub feishu_robot: FeishuRobotConfig, + #[serde(default)] + pub telegram: TelegramConfig, + #[serde(default)] + pub serverchan3: ServerChan3Config, + #[serde(default)] + pub email: EmailConfig, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NotificationChannel { + Webhook, + Bark, + #[serde(rename = "pushplus", alias = "push_plus")] + PushPlus, + WecomApp, + WecomRobot, + DingtalkRobot, + DingtalkApp, + FeishuRobot, + Telegram, + Email, + #[serde(rename = "serverchan3", alias = "server_chan3")] + ServerChan3, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NotificationEventType { + Sms, + Ddns, + VersionUpdate, + SystemEvent, + DeviceStatus, + Automation, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MatcherOperator { + Always, + Contains, + NotContains, + Equals, + Regex, +} + +fn default_matcher_operator() -> MatcherOperator { + MatcherOperator::Always +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RuleMatcher { + #[serde(default)] + pub field: String, + #[serde(default = "default_matcher_operator")] + pub operator: MatcherOperator, + #[serde(default)] + pub value: String, +} + +impl Default for RuleMatcher { + fn default() -> Self { + Self { + field: "summary".to_string(), + operator: MatcherOperator::Always, + value: String::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QuietHoursSchedule { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub weekdays: Vec, + #[serde(default = "default_quiet_start")] + pub start: String, + #[serde(default = "default_quiet_end")] + pub end: String, +} + +fn default_quiet_start() -> String { + "22:00".to_string() +} + +fn default_quiet_end() -> String { + "08:00".to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct DeviceStatusSchedule { + #[serde(default = "default_device_status_schedule_mode")] + pub mode: String, + #[serde(default = "default_device_status_interval_minutes")] + pub interval_minutes: u32, + #[serde(default = "default_device_status_weekdays")] + pub weekdays: Vec, + #[serde(default = "default_device_status_times")] + pub times: Vec, +} + +impl Default for DeviceStatusSchedule { + fn default() -> Self { + Self { + mode: default_device_status_schedule_mode(), + interval_minutes: default_device_status_interval_minutes(), + weekdays: default_device_status_weekdays(), + times: default_device_status_times(), + } + } +} + +fn default_device_status_schedule_mode() -> String { + "fixed".to_string() +} + +fn default_device_status_interval_minutes() -> u32 { + 24 * 60 +} + +fn default_device_status_weekdays() -> Vec { + vec![1, 2, 3, 4, 5, 6, 7] +} + +fn default_device_status_times() -> Vec { + vec!["09:00".to_string()] +} + +fn default_device_status_sms_period() -> String { + "last_24h".to_string() +} + +pub fn default_device_status_items() -> Vec { + [ + "device_power", + "device_model", + "system_version", + "uptime", + "work_mode", + "sim_present", + "sim_operator", + "cellular_registration", + "cellular_operator", + "cellular_technology", + "signal_strength", + "data_connection", + "airplane_mode", + "roaming", + "ipv4_connectivity", + "ipv6_connectivity", + "default_route", + "default_ip", + "wlan_enabled", + "wlan_connected", + "wlan_ssid", + "key_interfaces", + "cellular_traffic", + "cpu_usage", + "memory_usage", + "root_disk", + "top_temperatures", + "service_version", + "ddns_status", + "ota_status", + "forwarding_channels", + "forwarding_rules", + "sms_forwarding_stats", + ] + .into_iter() + .map(ToString::to_string) + .collect() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotificationRule { + pub id: String, + #[serde(rename = "type")] + pub event_type: NotificationEventType, + pub name: String, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub matcher: RuleMatcher, + #[serde(default)] + pub channel_ids: Vec, + #[serde(default)] + pub event_codes: Vec, + #[serde(default)] + pub title_template: String, + #[serde(default)] + pub template: String, + #[serde(default)] + pub quiet_hours: Vec, + #[serde(default = "default_ddns_failure_threshold")] + pub ddns_failure_threshold: u32, + #[serde(default = "default_device_status_items")] + pub device_status_items: Vec, + #[serde(default)] + pub device_status_schedule: DeviceStatusSchedule, + #[serde(default = "default_device_status_sms_period")] + pub device_status_sms_period: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotificationChannelInstance { + pub id: String, + #[serde(rename = "type")] + pub channel_type: NotificationChannel, + pub name: String, + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub rate_limit: NotificationRateLimitConfig, + #[serde(default)] + pub config: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotificationRateLimitConfig { + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default = "default_notification_rate_limit_max_messages")] + pub max_messages: u32, + #[serde(default = "default_notification_rate_limit_window_seconds")] + pub window_seconds: u32, +} + +impl Default for NotificationRateLimitConfig { + fn default() -> Self { + Self { + enabled: true, + max_messages: default_notification_rate_limit_max_messages(), + window_seconds: default_notification_rate_limit_window_seconds(), + } + } +} + +fn default_notification_rate_limit_max_messages() -> u32 { + 20 +} + +fn default_notification_rate_limit_window_seconds() -> u32 { + 60 +} + +fn default_ddns_failure_threshold() -> u32 { + 1 +} + +fn default_notification_log_retention_days() -> u32 { + 90 +} + +fn default_notification_log_max_entries() -> u32 { + 10_000 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotificationLogCleanupConfig { + #[serde(default = "default_true")] + pub retention_days_enabled: bool, + #[serde(default = "default_notification_log_retention_days")] + pub retention_days: u32, + #[serde(default = "default_true")] + pub max_entries_enabled: bool, + #[serde(default = "default_notification_log_max_entries")] + pub max_entries: u32, +} + +impl Default for NotificationLogCleanupConfig { + fn default() -> Self { + Self { + retention_days_enabled: true, + retention_days: default_notification_log_retention_days(), + max_entries_enabled: true, + max_entries: default_notification_log_max_entries(), + } + } +} + +fn default_notification_version() -> u8 { + 2 +} + +#[derive(Debug, Clone, Serialize)] +pub struct NotificationConfig { + #[serde(default = "default_notification_version")] + pub version: u8, + #[serde(default)] + pub channels: Vec, + #[serde(default)] + pub rules: Vec, + #[serde(default)] + pub log_cleanup: NotificationLogCleanupConfig, +} + +#[derive(Deserialize)] +struct NotificationConfigV2 { + #[serde(default = "default_notification_version", rename = "version")] + _version: u8, + #[serde(default)] + channels: Vec, + #[serde(default)] + rules: Vec, + #[serde(default)] + log_cleanup: NotificationLogCleanupConfig, +} + +impl<'de> Deserialize<'de> for NotificationConfig { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = Value::deserialize(deserializer)?; + let is_v2 = value.get("channels").is_some() || value.get("rules").is_some(); + if is_v2 { + let parsed: NotificationConfigV2 = + serde_json::from_value(value).map_err(D::Error::custom)?; + return Ok(Self { + version: 2, + channels: parsed.channels, + rules: parsed.rules, + log_cleanup: parsed.log_cleanup, + }); + } + + let legacy: LegacyNotificationConfig = + serde_json::from_value(value).map_err(D::Error::custom)?; + Ok(Self::from_legacy(legacy)) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct DeviceNetworkConfig { + #[serde(default)] + pub ddns: DdnsConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct VersionUpdateNotificationConfig { + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub proxy_prefix: String, + #[serde(default)] + pub last_notified_version: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct SecurityConfig { + #[serde(default = "default_true")] + pub password_protection_enabled: bool, + #[serde(default = "default_password_min_length")] + pub password_min_length: u8, + #[serde(default = "default_true")] + pub password_require_letters: bool, + #[serde(default = "default_true")] + pub password_require_digits: bool, + #[serde(default = "default_true")] + pub password_require_symbols: bool, + #[serde(default = "default_session_ttl_seconds")] + pub session_ttl_seconds: i64, + #[serde(default = "default_idle_timeout_seconds")] + pub idle_timeout_seconds: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct DdnsConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_ddns_provider")] + pub provider: String, + #[serde(default)] + pub access_id: String, + #[serde(default)] + pub access_secret: String, + #[serde(default = "default_ddns_interval_seconds")] + pub interval_seconds: u64, + #[serde(default = "default_ddns_ttl")] + pub ttl: u32, + #[serde(default)] + pub ipv4: DdnsIpConfig, + #[serde(default = "default_ddns_ipv6_config")] + pub ipv6: DdnsIpConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct DdnsIpConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_ddns_get_type")] + pub get_type: String, + #[serde(default)] + pub interface_name: String, + #[serde(default)] + pub urls: Vec, + #[serde(default)] + pub domains: Vec, +} + +fn default_true() -> bool { + true +} + +/// 默认短信模板 +fn default_sms_template() -> String { + r#"{ + "msg_type": "text", + "content": { + "text": "📱 短信通知\n号码: {{phone_number}}\n内容: {{content}}\n时间: {{timestamp}}\n来源: {{own_number}}" + } +}"# + .to_string() +} + +/// 默认通话模板 +fn default_call_template() -> String { + r#"{ + "msg_type": "text", + "content": { + "text": "📞 来电通知\n号码: {{phone_number}}\n类型: {{direction}}\n时间: {{start_time}}\n时长: {{duration}}秒\n已接听: {{answered}}" + } +}"#.to_string() +} + +fn default_ddns_template() -> String { + r#"{ + "msg_type": "text", + "content": { + "text": "SimAdmin DDNS 通知\n域名: {{domains}}\nIP类型: {{ip_type}}\n新IP: {{new_ip}}\n旧IP: {{old_ip}}\n服务商: {{provider}}\n记录类型: {{record_type}}\n状态: {{status}}\n消息: {{message}}\n更新时间: {{timestamp}}" + } +}"# + .to_string() +} + +fn default_update_template() -> String { + r#"{ + "msg_type": "text", + "content": { + "text": "🚀 SimAdmin 发现新版本\n固件包: {{asset_name}}\n版本号: {{version}}\n时间: {{time}}\n来源: {{own_number}}\n\n请前往 OTA 在线更新模块检测版本,一键下载并升级。" + } +}"# + .to_string() +} + +fn default_plain_sms_template() -> String { + "📱 短信通知\n号码: {{发送方号码}}\n内容: {{短信内容}}\n时间: {{时间}}\n来源: {{本机号码}}" + .to_string() +} + +fn default_plain_call_template() -> String { + "📞 来电通知\n号码: {{phone_number}}\n类型: {{direction}}\n时间: {{start_time}}\n时长: {{duration}}秒\n已接听: {{answered}}".to_string() +} + +fn default_plain_ddns_template() -> String { + "SimAdmin DDNS 通知\n域名: {{域名}}\nIP类型: {{IP类型}}\n新IP: {{新IP}}\n旧IP: {{旧IP}}\n服务商: {{服务商}}\n记录类型: {{记录类型}}\n状态: {{状态}}\n消息: {{消息}}\n更新时间: {{更新时间}}".to_string() +} + +fn default_plain_update_template() -> String { + "🚀 SimAdmin 发现新版本\n固件包: {{固件包}}\n版本号: {{版本号}}\n时间: {{时间}}\n来源: {{本机号码}}\n\n请前往 OTA 在线更新模块检测版本,一键下载并升级。".to_string() +} + +fn default_sms_title_template() -> String { + "SimAdmin 短信通知".to_string() +} + +fn default_bark_server_url() -> String { + "https://api.day.app".to_string() +} + +fn default_pushplus_template() -> String { + "txt".to_string() +} + +fn default_wecom_to_user() -> String { + "@all".to_string() +} + +fn default_dingtalk_msg_key() -> String { + "sampleText".to_string() +} + +fn default_smtp_port() -> u16 { + 465 +} + +fn default_smtp_security() -> String { + "implicit_tls".to_string() +} + +fn default_email_message_format() -> String { + "plain".to_string() +} + +impl Default for WebhookConfig { + fn default() -> Self { + Self { + enabled: false, + url: String::new(), + forward_sms: true, + forward_calls: true, + forward_ddns: true, + forward_updates: true, + headers: HashMap::new(), + secret: String::new(), + sms_template: default_sms_template(), + call_template: default_call_template(), + ddns_template: default_ddns_template(), + update_template: default_update_template(), + } + } +} + +impl Default for MessageChannelConfig { + fn default() -> Self { + Self { + enabled: false, + forward_sms: true, + forward_calls: true, + forward_ddns: true, + forward_updates: true, + sms_template: default_plain_sms_template(), + call_template: default_plain_call_template(), + ddns_template: default_plain_ddns_template(), + update_template: default_plain_update_template(), + } + } +} + +impl Default for BarkConfig { + fn default() -> Self { + Self { + common: MessageChannelConfig::default(), + server_url: default_bark_server_url(), + device_key: String::new(), + title_template: default_sms_title_template(), + group: String::new(), + sound: String::new(), + level: String::new(), + icon: String::new(), + click_url: String::new(), + copy: String::new(), + auto_copy: false, + save_history: true, + } + } +} + +impl Default for PushPlusConfig { + fn default() -> Self { + Self { + common: MessageChannelConfig::default(), + token: String::new(), + title_template: default_sms_title_template(), + topic: String::new(), + template: default_pushplus_template(), + channel: String::new(), + option: String::new(), + callback_url: String::new(), + } + } +} + +impl Default for WecomAppConfig { + fn default() -> Self { + Self { + common: MessageChannelConfig::default(), + corp_id: String::new(), + agent_id: String::new(), + secret: String::new(), + to_user: default_wecom_to_user(), + to_party: String::new(), + to_tag: String::new(), + safe: false, + } + } +} + +impl Default for WecomRobotConfig { + fn default() -> Self { + Self { + common: MessageChannelConfig::default(), + webhook_url: String::new(), + key: String::new(), + } + } +} + +impl Default for DingtalkRobotConfig { + fn default() -> Self { + Self { + common: MessageChannelConfig::default(), + webhook_url: String::new(), + access_token: String::new(), + secret: String::new(), + at_mobiles: String::new(), + at_all: false, + } + } +} + +impl Default for DingtalkAppConfig { + fn default() -> Self { + Self { + common: MessageChannelConfig::default(), + app_key: String::new(), + app_secret: String::new(), + robot_code: String::new(), + open_conversation_id: String::new(), + msg_key: default_dingtalk_msg_key(), + } + } +} + +impl Default for FeishuRobotConfig { + fn default() -> Self { + Self { + common: MessageChannelConfig::default(), + webhook_url: String::new(), + token: String::new(), + secret: String::new(), + } + } +} + +impl Default for TelegramConfig { + fn default() -> Self { + Self { + common: MessageChannelConfig::default(), + bot_token: String::new(), + chat_id: String::new(), + parse_mode: String::new(), + disable_web_page_preview: true, + } + } +} + +impl Default for ServerChan3Config { + fn default() -> Self { + Self { + common: MessageChannelConfig::default(), + send_key: String::new(), + uid: String::new(), + channel: String::new(), + openid: String::new(), + } + } +} + +impl Default for EmailConfig { + fn default() -> Self { + Self { + common: MessageChannelConfig::default(), + smtp_host: String::new(), + smtp_port: default_smtp_port(), + smtp_security: default_smtp_security(), + allow_insecure_tls: false, + username: String::new(), + password: String::new(), + sender_address: String::new(), + sender_name: String::new(), + receiver_addresses: String::new(), + message_format: default_email_message_format(), + } + } +} + +impl Default for LegacyNotificationConfig { + fn default() -> Self { + Self { + webhook: WebhookConfig::default(), + bark: BarkConfig::default(), + pushplus: PushPlusConfig::default(), + wecom_app: WecomAppConfig::default(), + wecom_robot: WecomRobotConfig::default(), + dingtalk_robot: DingtalkRobotConfig::default(), + dingtalk_app: DingtalkAppConfig::default(), + feishu_robot: FeishuRobotConfig::default(), + telegram: TelegramConfig::default(), + serverchan3: ServerChan3Config::default(), + email: EmailConfig::default(), + } + } +} + +impl Default for NotificationConfig { + fn default() -> Self { + Self { + version: 2, + channels: Vec::new(), + rules: Vec::new(), + log_cleanup: NotificationLogCleanupConfig::default(), + } + } +} + +struct LegacyChannelMigration { + id: String, + channel_type: NotificationChannel, + name: String, + enabled: bool, + config: Value, + forward_sms: bool, + forward_ddns: bool, + forward_updates: bool, + sms_template: String, + ddns_template: String, + update_template: String, +} + +impl NotificationConfig { + pub fn from_legacy(legacy: LegacyNotificationConfig) -> Self { + let migrations = legacy_channel_migrations(&legacy); + let channels = migrations + .iter() + .map(|item| NotificationChannelInstance { + id: item.id.clone(), + channel_type: item.channel_type, + name: item.name.clone(), + enabled: item.enabled, + rate_limit: NotificationRateLimitConfig::default(), + config: item.config.clone(), + }) + .collect::>(); + + let mut rules = Vec::new(); + push_legacy_rule( + &mut rules, + NotificationEventType::Sms, + "默认短信转发", + "legacy-sms", + &migrations, + ); + push_legacy_rule( + &mut rules, + NotificationEventType::Ddns, + "默认 DDNS 转发", + "legacy-ddns", + &migrations, + ); + push_legacy_rule( + &mut rules, + NotificationEventType::VersionUpdate, + "默认版本更新转发", + "legacy-version-update", + &migrations, + ); + + Self { + version: 2, + channels, + rules, + log_cleanup: NotificationLogCleanupConfig::default(), + } + } + + pub fn first_webhook_config(&self) -> Option { + self.channels + .iter() + .find(|channel| channel.channel_type == NotificationChannel::Webhook) + .and_then(|channel| serde_json::from_value(channel.config.clone()).ok()) + } +} + +fn channel_label(channel: NotificationChannel) -> &'static str { + match channel { + NotificationChannel::Webhook => "Webhook", + NotificationChannel::Bark => "Bark", + NotificationChannel::PushPlus => "PushPlus", + NotificationChannel::WecomApp => "企业微信应用消息", + NotificationChannel::WecomRobot => "企业微信群机器人", + NotificationChannel::DingtalkRobot => "钉钉群自定义机器人", + NotificationChannel::DingtalkApp => "钉钉企业内机器人", + NotificationChannel::FeishuRobot => "飞书机器人", + NotificationChannel::Telegram => "Telegram 机器人", + NotificationChannel::Email => "Email", + NotificationChannel::ServerChan3 => "Server酱3", + } +} + +fn config_value(config: &T) -> Value { + serde_json::to_value(config).unwrap_or(Value::Object(Default::default())) +} + +fn legacy_channel_migrations(legacy: &LegacyNotificationConfig) -> Vec { + let mut channels = Vec::new(); + + if legacy.webhook.enabled || !legacy.webhook.url.trim().is_empty() { + channels.push(LegacyChannelMigration { + id: "webhook-1".to_string(), + channel_type: NotificationChannel::Webhook, + name: channel_label(NotificationChannel::Webhook).to_string(), + enabled: legacy.webhook.enabled, + config: config_value(&legacy.webhook), + forward_sms: legacy.webhook.forward_sms, + forward_ddns: legacy.webhook.forward_ddns, + forward_updates: legacy.webhook.forward_updates, + sms_template: webhook_text_template( + &legacy.webhook.sms_template, + &default_rule_template(NotificationEventType::Sms), + ), + ddns_template: webhook_text_template( + &legacy.webhook.ddns_template, + &default_rule_template(NotificationEventType::Ddns), + ), + update_template: webhook_text_template( + &legacy.webhook.update_template, + &default_rule_template(NotificationEventType::VersionUpdate), + ), + }); + } + + push_message_channel_migration( + &mut channels, + NotificationChannel::Bark, + "bark-1", + &legacy.bark.common, + &legacy.bark, + legacy.bark.common.enabled || !legacy.bark.device_key.trim().is_empty(), + ); + push_message_channel_migration( + &mut channels, + NotificationChannel::PushPlus, + "pushplus-1", + &legacy.pushplus.common, + &legacy.pushplus, + legacy.pushplus.common.enabled || !legacy.pushplus.token.trim().is_empty(), + ); + push_message_channel_migration( + &mut channels, + NotificationChannel::WecomApp, + "wecom-app-1", + &legacy.wecom_app.common, + &legacy.wecom_app, + legacy.wecom_app.common.enabled + || !legacy.wecom_app.corp_id.trim().is_empty() + || !legacy.wecom_app.agent_id.trim().is_empty() + || !legacy.wecom_app.secret.trim().is_empty(), + ); + push_message_channel_migration( + &mut channels, + NotificationChannel::WecomRobot, + "wecom-robot-1", + &legacy.wecom_robot.common, + &legacy.wecom_robot, + legacy.wecom_robot.common.enabled + || !legacy.wecom_robot.webhook_url.trim().is_empty() + || !legacy.wecom_robot.key.trim().is_empty(), + ); + push_message_channel_migration( + &mut channels, + NotificationChannel::DingtalkRobot, + "dingtalk-robot-1", + &legacy.dingtalk_robot.common, + &legacy.dingtalk_robot, + legacy.dingtalk_robot.common.enabled + || !legacy.dingtalk_robot.webhook_url.trim().is_empty() + || !legacy.dingtalk_robot.access_token.trim().is_empty(), + ); + push_message_channel_migration( + &mut channels, + NotificationChannel::DingtalkApp, + "dingtalk-app-1", + &legacy.dingtalk_app.common, + &legacy.dingtalk_app, + legacy.dingtalk_app.common.enabled + || !legacy.dingtalk_app.app_key.trim().is_empty() + || !legacy.dingtalk_app.app_secret.trim().is_empty() + || !legacy.dingtalk_app.open_conversation_id.trim().is_empty(), + ); + push_message_channel_migration( + &mut channels, + NotificationChannel::FeishuRobot, + "feishu-robot-1", + &legacy.feishu_robot.common, + &legacy.feishu_robot, + legacy.feishu_robot.common.enabled + || !legacy.feishu_robot.webhook_url.trim().is_empty() + || !legacy.feishu_robot.token.trim().is_empty(), + ); + push_message_channel_migration( + &mut channels, + NotificationChannel::Telegram, + "telegram-1", + &legacy.telegram.common, + &legacy.telegram, + legacy.telegram.common.enabled + || !legacy.telegram.bot_token.trim().is_empty() + || !legacy.telegram.chat_id.trim().is_empty(), + ); + push_message_channel_migration( + &mut channels, + NotificationChannel::ServerChan3, + "serverchan3-1", + &legacy.serverchan3.common, + &legacy.serverchan3, + legacy.serverchan3.common.enabled + || !legacy.serverchan3.send_key.trim().is_empty() + || !legacy.serverchan3.uid.trim().is_empty(), + ); + push_message_channel_migration( + &mut channels, + NotificationChannel::Email, + "email-1", + &legacy.email.common, + &legacy.email, + legacy.email.common.enabled + || !legacy.email.smtp_host.trim().is_empty() + || !legacy.email.sender_address.trim().is_empty() + || !legacy.email.receiver_addresses.trim().is_empty(), + ); + + channels +} + +fn push_message_channel_migration( + channels: &mut Vec, + channel_type: NotificationChannel, + id: &str, + common: &MessageChannelConfig, + config: &T, + configured: bool, +) { + if !configured { + return; + } + channels.push(LegacyChannelMigration { + id: id.to_string(), + channel_type, + name: channel_label(channel_type).to_string(), + enabled: common.enabled, + config: config_value(config), + forward_sms: common.forward_sms, + forward_ddns: common.forward_ddns, + forward_updates: common.forward_updates, + sms_template: non_empty_template(&common.sms_template, NotificationEventType::Sms), + ddns_template: non_empty_template(&common.ddns_template, NotificationEventType::Ddns), + update_template: non_empty_template( + &common.update_template, + NotificationEventType::VersionUpdate, + ), + }); +} + +fn push_legacy_rule( + rules: &mut Vec, + event_type: NotificationEventType, + name: &str, + id: &str, + channels: &[LegacyChannelMigration], +) { + let selected = channels + .iter() + .filter(|channel| match event_type { + NotificationEventType::Sms => channel.forward_sms, + NotificationEventType::Ddns => channel.forward_ddns, + NotificationEventType::VersionUpdate => channel.forward_updates, + NotificationEventType::SystemEvent => false, + NotificationEventType::DeviceStatus => false, + NotificationEventType::Automation => false, + }) + .collect::>(); + if selected.is_empty() { + return; + } + + let template = selected + .first() + .map(|channel| match event_type { + NotificationEventType::Sms => channel.sms_template.clone(), + NotificationEventType::Ddns => channel.ddns_template.clone(), + NotificationEventType::VersionUpdate => channel.update_template.clone(), + NotificationEventType::SystemEvent => String::new(), + NotificationEventType::DeviceStatus => String::new(), + NotificationEventType::Automation => String::new(), + }) + .unwrap_or_else(|| default_rule_template(event_type)); + + rules.push(NotificationRule { + id: id.to_string(), + event_type, + name: name.to_string(), + enabled: true, + matcher: RuleMatcher::default(), + channel_ids: selected + .into_iter() + .map(|channel| channel.id.clone()) + .collect(), + event_codes: Vec::new(), + title_template: default_rule_title_template(event_type), + template, + quiet_hours: Vec::new(), + ddns_failure_threshold: default_ddns_failure_threshold(), + device_status_items: default_device_status_items(), + device_status_schedule: DeviceStatusSchedule::default(), + device_status_sms_period: default_device_status_sms_period(), + }); +} + +fn non_empty_template(template: &str, event_type: NotificationEventType) -> String { + if template.trim().is_empty() { + default_rule_template(event_type) + } else { + template.to_string() + } +} + +fn webhook_text_template(template: &str, fallback: &str) -> String { + if template.trim().is_empty() { + return fallback.to_string(); + } + if let Ok(value) = serde_json::from_str::(template) { + if let Some(text) = value + .get("content") + .and_then(|content| content.get("text")) + .and_then(Value::as_str) + { + return text.replace("\\n", "\n"); + } + if let Some(text) = value.get("text").and_then(Value::as_str) { + return text.replace("\\n", "\n"); + } + } + template.to_string() +} + +pub fn default_rule_template(event_type: NotificationEventType) -> String { + match event_type { + NotificationEventType::Sms => { + "📱 短信通知\n号码: {{发送方号码}}\n内容: {{短信内容}}\n时间: {{时间}}\n来源: {{本机号码}}".to_string() + } + NotificationEventType::Ddns => { + "DDNS 通知\n域名: {{域名}}\nIP 类型: {{IP类型}}\n新 IP: {{新IP}}\n旧 IP: {{旧IP}}\n服务商: {{服务商}}\n记录类型: {{记录类型}}\n状态: {{状态}}\n消息: {{消息}}\n更新时间: {{更新时间}}".to_string() + } + NotificationEventType::VersionUpdate => { + "🚀 SimAdmin 发现新版本\n固件包: {{固件包}}\n版本号: {{版本号}}\n时间: {{时间}}\n来源: {{本机号码}}".to_string() + } + NotificationEventType::SystemEvent => { + "系统事件通知\n分类: {{分类}}\n事件: {{事件}}\n等级: {{等级}}\n状态: {{状态}}\n对象: {{对象}}\n消息: {{消息}}\n时间: {{时间}}".to_string() + } + NotificationEventType::DeviceStatus => { + "设备状态报告\n【{{状态分类}}】\n{{状态内容}}\n\n时间: {{时间}}".to_string() + } + NotificationEventType::Automation => { + "🤖 自动化事件通知\n任务名称: {{任务名称}}\n任务类型: {{任务类型}}\n执行状态: {{任务状态}}\n详情: {{任务详情}}\n时间: {{触发时间}}\n来源: {{本机号码}}".to_string() + } + } +} + +pub fn default_rule_title_template(event_type: NotificationEventType) -> String { + match event_type { + NotificationEventType::Sms => "{{发送方号码}}:验证码{{验证码}}".to_string(), + NotificationEventType::Ddns => "DDNS通知:{{本机号码}}".to_string(), + NotificationEventType::VersionUpdate => "版本更新通知:{{本机号码}}".to_string(), + NotificationEventType::SystemEvent => "{{分类}}事件:{{本机号码}}".to_string(), + NotificationEventType::DeviceStatus => "设备状态:{{本机号码}}".to_string(), + NotificationEventType::Automation => "{{任务类型}}:{{本机号码}}".to_string(), + } +} + +impl Default for DeviceNetworkConfig { + fn default() -> Self { + Self { + ddns: DdnsConfig::default(), + } + } +} + +impl Default for VersionUpdateNotificationConfig { + fn default() -> Self { + Self { + enabled: true, + proxy_prefix: String::new(), + last_notified_version: None, + } + } +} + +impl Default for SecurityConfig { + fn default() -> Self { + Self { + password_protection_enabled: true, + password_min_length: default_password_min_length(), + password_require_letters: true, + password_require_digits: true, + password_require_symbols: true, + session_ttl_seconds: default_session_ttl_seconds(), + idle_timeout_seconds: default_idle_timeout_seconds(), + } + } +} + +impl Default for DdnsConfig { + fn default() -> Self { + Self { + enabled: false, + provider: default_ddns_provider(), + access_id: String::new(), + access_secret: String::new(), + interval_seconds: default_ddns_interval_seconds(), + ttl: default_ddns_ttl(), + ipv4: DdnsIpConfig { + enabled: true, + get_type: default_ddns_get_type(), + interface_name: String::new(), + urls: default_ddns_ipv4_urls(), + domains: Vec::new(), + }, + ipv6: default_ddns_ipv6_config(), + } + } +} + +impl Default for DdnsIpConfig { + fn default() -> Self { + Self { + enabled: false, + get_type: default_ddns_get_type(), + interface_name: String::new(), + urls: Vec::new(), + domains: Vec::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AutomationConfig { + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub tasks: Vec, +} + +impl Default for AutomationConfig { + fn default() -> Self { + Self { + enabled: true, + tasks: Vec::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AutomationTask { + pub id: String, + pub name: String, + #[serde(default = "default_true")] + pub enabled: bool, + pub trigger: AutomationTrigger, + pub action: AutomationAction, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", content = "config", rename_all = "snake_case")] +pub enum AutomationTrigger { + Fixed { weekdays: Vec, times: Vec }, + Interval { interval_value: u64, interval_unit: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", content = "config", rename_all = "snake_case")] +pub enum AutomationAction { + RestartBaseband, + RebootDevice { delay_seconds: u32 }, + SendSms { + phone_number: String, + content: String, + random_delay_seconds: Option, + retry_limit: Option, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn notification_channel_accepts_frontend_pushplus_key() { + assert!(matches!( + serde_json::from_str::(r#""pushplus""#).unwrap(), + NotificationChannel::PushPlus + )); + assert!(matches!( + serde_json::from_str::(r#""push_plus""#).unwrap(), + NotificationChannel::PushPlus + )); + assert_eq!( + serde_json::to_string(&NotificationChannel::PushPlus).unwrap(), + r#""pushplus""# + ); + } + + #[test] + fn notification_channel_accepts_email_and_serverchan3_keys() { + assert!(matches!( + serde_json::from_str::(r#""email""#).unwrap(), + NotificationChannel::Email + )); + assert!(matches!( + serde_json::from_str::(r#""serverchan3""#).unwrap(), + NotificationChannel::ServerChan3 + )); + assert_eq!( + serde_json::to_string(&NotificationChannel::Email).unwrap(), + r#""email""# + ); + assert_eq!( + serde_json::to_string(&NotificationChannel::ServerChan3).unwrap(), + r#""serverchan3""# + ); + } + + #[test] + fn email_config_accepts_legacy_receiver_address() { + let config: EmailConfig = serde_json::from_str( + r#"{"smtp_host":"smtp.example.com","receiver_address":"admin@example.com"}"#, + ) + .unwrap(); + + assert_eq!(config.smtp_security, "implicit_tls"); + assert_eq!(config.receiver_addresses, "admin@example.com"); + } + + #[test] + fn legacy_notification_config_migrates_channels_and_rules() { + let mut legacy = LegacyNotificationConfig::default(); + legacy.webhook.enabled = true; + legacy.webhook.url = "https://example.com/hook".to_string(); + legacy.webhook.forward_sms = true; + legacy.webhook.forward_ddns = false; + legacy.webhook.forward_updates = true; + + let migrated = NotificationConfig::from_legacy(legacy); + + assert_eq!(migrated.version, 2); + assert_eq!(migrated.channels.len(), 1); + assert_eq!(migrated.channels[0].id, "webhook-1"); + assert_eq!( + migrated.channels[0].channel_type, + NotificationChannel::Webhook + ); + assert!(migrated.channels[0].enabled); + assert!(migrated + .rules + .iter() + .any(|rule| rule.event_type == NotificationEventType::Sms + && rule.channel_ids == vec!["webhook-1".to_string()])); + assert!(!migrated + .rules + .iter() + .any(|rule| rule.event_type == NotificationEventType::Ddns)); + assert!(migrated + .rules + .iter() + .any(|rule| rule.event_type == NotificationEventType::VersionUpdate)); + } + + #[test] + fn update_template_migration_removes_obsolete_commit_and_md5_lines() { + let mut config = AppConfig::default(); + config.notifications.rules.push(NotificationRule { + id: "rule-update".to_string(), + event_type: NotificationEventType::VersionUpdate, + name: "版本更新".to_string(), + enabled: true, + matcher: RuleMatcher::default(), + channel_ids: Vec::new(), + event_codes: Vec::new(), + title_template: String::new(), + template: "版本号: {{版本号}}\nCommit: {{Commit}}\n构建时间: {{构建时间}}\nMD5: {{MD5}}\n来源: {{本机号码}}".to_string(), + quiet_hours: Vec::new(), + ddns_failure_threshold: 1, + device_status_items: default_device_status_items(), + device_status_schedule: DeviceStatusSchedule::default(), + device_status_sms_period: "last_24h".to_string(), + }); + + assert!(migrate_update_templates(&mut config)); + + let template = &config.notifications.rules[0].template; + assert_eq!( + template, + "版本号: {{版本号}}\n时间: {{时间}}\n来源: {{本机号码}}" + ); + } +} + +fn default_ddns_provider() -> String { + "tencentcloud".to_string() +} + +fn default_ddns_interval_seconds() -> u64 { + 300 +} + +fn default_ddns_ttl() -> u32 { + 600 +} + +fn default_ddns_get_type() -> String { + "interface".to_string() +} + +fn default_ddns_ipv4_urls() -> Vec { + vec![ + "https://api.ipify.org".to_string(), + "https://ip.3322.net".to_string(), + "https://4.ident.me".to_string(), + "https://ddns.oray.com/checkip".to_string(), + "https://4.ipw.cn".to_string(), + ] +} + +fn default_ddns_ipv6_urls() -> Vec { + vec![ + "https://api6.ipify.org".to_string(), + "https://speed.neu6.edu.cn/getIP.php".to_string(), + "https://v6.ident.me".to_string(), + "https://myip6.ipip.net".to_string(), + "https://6.ipw.cn".to_string(), + ] +} + +fn default_ddns_ipv6_config() -> DdnsIpConfig { + DdnsIpConfig { + enabled: false, + get_type: default_ddns_get_type(), + interface_name: String::new(), + urls: default_ddns_ipv6_urls(), + domains: Vec::new(), + } +} + +fn default_roaming_allowed() -> bool { + true +} + +fn default_data_enabled() -> bool { + false +} + +fn default_password_min_length() -> u8 { + 8 +} + +fn default_session_ttl_seconds() -> i64 { + 7 * 24 * 60 * 60 +} + +fn default_idle_timeout_seconds() -> i64 { + 60 * 60 +} + +fn default_apn_protocol() -> String { + "dual".to_string() +} + +fn default_apn_auth_method() -> String { + "chap".to_string() +} + +fn default_lpac_path() -> String { + "/opt/simadmin/lpac/lpac".to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApnConfig { + #[serde(default)] + pub apn: String, + #[serde(default = "default_apn_protocol")] + pub protocol: String, + #[serde(default)] + pub username: String, + #[serde(default)] + pub password: String, + #[serde(default = "default_apn_auth_method")] + pub auth_method: String, +} + +impl Default for ApnConfig { + fn default() -> Self { + Self { + apn: String::new(), + protocol: default_apn_protocol(), + username: String::new(), + password: String::new(), + auth_method: default_apn_auth_method(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EsimConfig { + #[serde(default = "default_lpac_path")] + pub lpac_path: String, + #[serde(default)] + pub custom_memory_total_kb: Option, +} + +impl Default for EsimConfig { + fn default() -> Self { + Self { + lpac_path: default_lpac_path(), + custom_memory_total_kb: None, + } + } +} + +/// 应用配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppConfig { + #[serde(default)] + pub webhook: WebhookConfig, + #[serde(default)] + pub notifications: NotificationConfig, + #[serde(default)] + pub device_network: DeviceNetworkConfig, + #[serde(default)] + pub version_update_notifications: VersionUpdateNotificationConfig, + #[serde(default)] + pub security: SecurityConfig, + /// 是否允许蜂窝数据漫游(写入 ModemManager Simple.Connect 的 allow-roaming) + #[serde(default = "default_roaming_allowed")] + pub roaming_allowed: bool, + #[serde(default = "default_data_enabled")] + pub data_enabled: bool, + #[serde(default)] + pub apn: ApnConfig, + #[serde(default)] + pub work_mode: WorkMode, + #[serde(default)] + pub esim: EsimConfig, + #[serde(default)] + pub automation: AutomationConfig, +} + +impl Default for AppConfig { + fn default() -> Self { + Self { + webhook: WebhookConfig::default(), + notifications: NotificationConfig::default(), + device_network: DeviceNetworkConfig::default(), + version_update_notifications: VersionUpdateNotificationConfig::default(), + security: SecurityConfig::default(), + roaming_allowed: default_roaming_allowed(), + data_enabled: default_data_enabled(), + apn: ApnConfig::default(), + work_mode: WorkMode::default(), + esim: EsimConfig::default(), + automation: AutomationConfig::default(), + } + } +} + +fn migrate_legacy_webhook_config(config: &mut AppConfig) { + if config.notifications.channels.is_empty() + && config.notifications.rules.is_empty() + && config.webhook != WebhookConfig::default() + { + let mut legacy = LegacyNotificationConfig::default(); + legacy.webhook = config.webhook.clone(); + config.notifications = NotificationConfig::from_legacy(legacy); + } + config.webhook = config + .notifications + .first_webhook_config() + .unwrap_or_else(WebhookConfig::default); +} + +fn migrate_update_template_string(template: &mut String) -> bool { + let mut changed = false; + let md5_patterns = [ + "OTA包 MD5: {{md5}}", + "OTA包 MD5: {{MD5}}", + "OTA包MD5: {{md5}}", + "OTA包MD5: {{MD5}}", + "MD5: {{md5}}", + "MD5: {{MD5}}", + "校验值: {{md5}}", + "校验值: {{MD5}}", + "二进制MD5: {{binary_md5}}", + "前端MD5: {{frontend_md5}}", + "{{md5}}", + "{{MD5}}", + "{{binary_md5}}", + "{{frontend_md5}}", + ]; + let commit_patterns = [ + "Commit: {{commit}}", + "Commit: {{Commit}}", + "commit: {{commit}}", + "commit: {{Commit}}", + "提交: {{commit}}", + "提交: {{Commit}}", + "发布目标: {{target_commitish}}", + "{{commit}}", + "{{Commit}}", + "{{target_commitish}}", + ]; + + for pattern in md5_patterns.into_iter().chain(commit_patterns) { + // Try replacing with leading newline (escaped JSON or real) + let with_escaped_newline = format!("\\n{}", pattern); + if template.contains(&with_escaped_newline) { + *template = template.replace(&with_escaped_newline, ""); + changed = true; + } + let with_newline = format!("\n{}", pattern); + if template.contains(&with_newline) { + *template = template.replace(&with_newline, ""); + changed = true; + } + + // Try replacing with trailing newline (escaped JSON or real) + let with_escaped_trailing = format!("{}\\n", pattern); + if template.contains(&with_escaped_trailing) { + *template = template.replace(&with_escaped_trailing, ""); + changed = true; + } + let with_trailing = format!("{}\n", pattern); + if template.contains(&with_trailing) { + *template = template.replace(&with_trailing, ""); + changed = true; + } + + // Fallback: replace pattern directly + if template.contains(pattern) { + *template = template.replace(pattern, ""); + changed = true; + } + } + + let time_replacements = [ + ("构建时间: {{构建时间}}", "时间: {{时间}}"), + ("构建时间: {{build_time}}", "时间: {{time}}"), + ("{{build_time}}", "{{time}}"), + ("{{构建时间}}", "{{时间}}"), + ]; + for (old, new) in time_replacements { + if template.contains(old) { + *template = template.replace(old, new); + changed = true; + } + } + + changed +} + +fn migrate_update_templates(config: &mut AppConfig) -> bool { + let mut changed = false; + + // 1. Webhook template + if migrate_update_template_string(&mut config.webhook.update_template) { + changed = true; + } + + // 2. Notification rules templates + for rule in &mut config.notifications.rules { + if rule.event_type == NotificationEventType::VersionUpdate { + if migrate_update_template_string(&mut rule.template) { + changed = true; + } + } + } + + // 3. Notification channels templates + for channel in &mut config.notifications.channels { + if let Some(obj) = channel.config.as_object_mut() { + // E.g. BarkConfig, PushPlusConfig, WecomAppConfig etc have nested "common" + if let Some(common) = obj.get_mut("common").and_then(|v| v.as_object_mut()) { + if let Some(serde_json::Value::String(tpl)) = common.get_mut("update_template") { + if migrate_update_template_string(tpl) { + changed = true; + } + } + } + if let Some(serde_json::Value::String(tpl)) = obj.get_mut("update_template") { + if migrate_update_template_string(tpl) { + changed = true; + } + } + } + } + + changed +} + +/// 配置管理器 +pub struct ConfigManager { + config: Arc>, + config_path: PathBuf, +} + +impl ConfigManager { + /// 创建新的配置管理器 + pub fn new(config_path: PathBuf) -> Self { + let mut config = if config_path.exists() { + match fs::read_to_string(&config_path) { + Ok(content) => match serde_json::from_str::(&content) { + Ok(cfg) => cfg, + Err(e) => { + warn!(error = %e, "Failed to parse config file, using defaults"); + AppConfig::default() + } + }, + Err(e) => { + warn!(error = %e, "Failed to read config file, using defaults"); + AppConfig::default() + } + } + } else { + info!("No config file found, using defaults"); + AppConfig::default() + }; + + migrate_legacy_webhook_config(&mut config); + let changed = migrate_update_templates(&mut config); + + let manager = Self { + config: Arc::new(RwLock::new(config)), + config_path, + }; + + // 保存配置(如果文件不存在,或者配置模板发生了自动清理) + if !manager.config_path.exists() || changed { + let _ = manager.save(); + } + + manager + } + + /// 获取通知配置 + pub fn get_notifications(&self) -> NotificationConfig { + self.config.read().unwrap().notifications.clone() + } + + /// 获取自动化配置 + pub fn get_automation_config(&self) -> AutomationConfig { + self.config.read().unwrap().automation.clone() + } + + /// 更新自动化配置 + pub fn set_automation_config(&self, automation: AutomationConfig) -> Result<(), String> { + { + let mut config = self.config.write().unwrap(); + config.automation = automation; + } + self.save() + } + + pub fn get_roaming_allowed(&self) -> bool { + self.config.read().unwrap().roaming_allowed + } + + pub fn get_data_enabled(&self) -> bool { + self.config.read().unwrap().data_enabled + } + + pub fn get_apn_config(&self) -> ApnConfig { + self.config.read().unwrap().apn.clone() + } + + pub fn get_work_mode(&self) -> WorkMode { + self.config.read().unwrap().work_mode + } + + pub fn get_esim_config(&self) -> EsimConfig { + self.config.read().unwrap().esim.clone() + } + + pub fn set_esim_config(&self, esim: EsimConfig) -> Result<(), String> { + { + let mut c = self.config.write().unwrap(); + c.esim = esim; + } + self.save() + } + + pub fn get_device_network(&self) -> DeviceNetworkConfig { + self.config.read().unwrap().device_network.clone() + } + + pub fn get_ddns_config(&self) -> DdnsConfig { + self.config.read().unwrap().device_network.ddns.clone() + } + + pub fn get_version_update_notifications(&self) -> VersionUpdateNotificationConfig { + self.config + .read() + .unwrap() + .version_update_notifications + .clone() + } + + pub fn get_security(&self) -> SecurityConfig { + self.config.read().unwrap().security.clone() + } + + pub fn set_security(&self, security: SecurityConfig) -> Result<(), String> { + { + let mut c = self.config.write().unwrap(); + c.security = security; + } + self.save() + } + + pub fn set_data_enabled(&self, enabled: bool) -> Result<(), String> { + { + let mut c = self.config.write().unwrap(); + c.data_enabled = enabled; + } + self.save() + } + + pub fn set_apn_config(&self, apn: ApnConfig) -> Result<(), String> { + { + let mut c = self.config.write().unwrap(); + c.apn = apn; + } + self.save() + } + + pub fn set_work_mode(&self, mode: WorkMode) -> Result<(), String> { + { + let mut c = self.config.write().unwrap(); + c.work_mode = mode; + } + self.save() + } + + pub fn set_roaming_allowed(&self, allowed: bool) -> Result<(), String> { + { + let mut c = self.config.write().unwrap(); + c.roaming_allowed = allowed; + } + self.save() + } + + pub fn set_ddns_config(&self, ddns: DdnsConfig) -> Result<(), String> { + { + let mut c = self.config.write().unwrap(); + c.device_network.ddns = ddns; + } + self.save() + } + + pub fn set_last_notified_update_version(&self, version: String) -> Result<(), String> { + { + let mut c = self.config.write().unwrap(); + c.version_update_notifications.last_notified_version = Some(version); + } + self.save() + } + + /// 更新通知配置 + pub fn set_notifications(&self, notifications: NotificationConfig) -> Result<(), String> { + { + let mut config = self.config.write().unwrap(); + config.webhook = notifications + .first_webhook_config() + .unwrap_or_else(WebhookConfig::default); + config.notifications = notifications; + } + self.save() + } + + /// 保存配置到文件 + pub fn save(&self) -> Result<(), String> { + let config = self.config.read().unwrap(); + let content = serde_json::to_string_pretty(&*config) + .map_err(|e| format!("Failed to serialize config: {}", e))?; + + // 确保目录存在 + if let Some(parent) = self.config_path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create config directory: {}", e))?; + } + + fs::write(&self.config_path, content) + .map_err(|e| format!("Failed to write config file: {}", e))?; + + Ok(()) + } +} + +/// 获取默认配置文件路径 +pub fn get_default_config_path() -> PathBuf { + // 尝试 /data/config.json(设备上的持久化目录) + let device_path = PathBuf::from("/data/config.json"); + if device_path.parent().map(|p| p.exists()).unwrap_or(false) { + return device_path; + } + + // 回退到当前目录 + std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + .unwrap_or_else(|| PathBuf::from(".")) + .join("config.json") +} diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/backend/src/db.rs b/packages/operation-registry/test/fixtures/upstream-58e2204/backend/src/db.rs new file mode 100644 index 0000000..f29f187 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/backend/src/db.rs @@ -0,0 +1,2399 @@ +//! 数据库模块 +//! +//! 使用 SQLite 存储短信历史记录和通话记录 + +use chrono::{DateTime, Duration, FixedOffset, NaiveDateTime, Utc}; +use rusqlite::{params, Connection, OptionalExtension, Result, Row}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +const BEIJING_UTC_OFFSET_SECONDS: i32 = 8 * 60 * 60; +const SMS_TIMESTAMP_FORMAT: &str = "%Y-%m-%d %H:%M:%S"; + +/// 短信记录 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SmsMessage { + pub id: i64, + pub direction: String, // "incoming" 或 "outgoing" + pub phone_number: String, // 发件人或收件人 + pub content: String, // 短信内容 + pub timestamp: String, // ISO 8601 格式时间 + pub status: String, // "pending", "sent", "failed", "received" + pub pdu: Option, // 原始 PDU(如果有) +} + +/// 通话记录 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CallRecord { + pub id: i64, + pub direction: String, // "incoming" / "outgoing" / "missed" + pub phone_number: String, // 电话号码 + pub duration: i64, // 通话时长(秒) + pub start_time: String, // 开始时间 ISO 8601 + pub end_time: Option, // 结束时间 ISO 8601 + pub answered: bool, // 是否接通 +} + +/// 短信统计 +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct SmsStats { + pub total: i64, + pub incoming: i64, + pub outgoing: i64, + #[serde(default)] + pub pushed: i64, + #[serde(default)] + pub push_attempted: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotificationLogEntry { + pub id: i64, + pub event_type: String, + pub status: String, + pub summary: String, + pub rule_id: String, + pub rule_name: String, + pub channel_id: String, + pub channel_name: String, + pub message: String, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct NotificationLogsResponse { + pub logs: Vec, + pub total: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AutomationLogEntry { + pub id: i64, + pub task_id: String, + pub task_name: String, + pub task_type: String, + pub status: String, + pub detail: String, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AutomationLogsResponse { + pub logs: Vec, + pub total: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct NotificationStatusCounts { + pub success: i64, + pub failed: i64, + pub quiet_hours: i64, + pub unmatched: i64, + pub no_available_channel: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PeriodSmsStats { + pub incoming: i64, + pub forwarding: NotificationStatusCounts, +} + +pub struct NewNotificationLog<'a> { + pub event_type: &'a str, + pub status: &'a str, + pub summary: &'a str, + pub rule_id: &'a str, + pub rule_name: &'a str, + pub channel_id: &'a str, + pub channel_name: &'a str, + pub message: &'a str, +} + +pub struct NewNotificationQueueItem<'a> { + pub status: &'a str, + pub event_type: &'a str, + pub event_label: &'a str, + pub summary: &'a str, + pub reason: &'a str, + pub rule_id: &'a str, + pub rule_name: &'a str, + pub channel_id: &'a str, + pub channel_name: &'a str, + pub channel_type: &'a str, + pub title: &'a str, + pub body: &'a str, + pub next_attempt_at: &'a str, + pub max_attempts: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotificationQueueEntry { + pub id: i64, + pub status: String, + pub event_type: String, + pub event_label: String, + pub summary: String, + pub reason: String, + pub channel_id: String, + pub channel_name: String, + pub channel_type: String, + pub rule_id: String, + pub rule_name: String, + pub title: String, + pub body: String, + pub next_attempt_at: String, + pub attempt_count: i64, + pub max_attempts: i64, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct NotificationQueueResponse { + pub items: Vec, + pub total: i64, +} + +/// 通话统计 +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct CallStats { + pub total: i64, + pub incoming: i64, + pub outgoing: i64, + pub missed: i64, + pub total_duration: i64, // 总通话时长(秒) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SmscCacheEntry { + pub sms_center: String, + pub source: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OwnNumberCacheEntry { + pub phone_numbers: Vec, + pub source: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SmsStorageCacheEntry { + pub sms_used: Option, + pub sms_total: Option, + pub source: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct EsimProfileCacheEntry { + pub iccid: String, + pub name: Option, + pub provider: Option, + pub state: Option, + pub profile_class: Option, + pub imsi: Option, + pub msisdn: Option, + pub smsc: Option, + pub smdp: Option, + pub matching_id: Option, + pub isdp_aid: Option, + pub mcc: Option, + pub mnc: Option, + pub disable_allowed: Option, + pub delete_allowed: Option, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct EsimEuiccCacheEntry { + pub cache_key: String, + pub eid: String, + pub status: String, + pub manufacturer: String, + pub memory_total_kb: Option, + pub memory_available_kb: Option, + pub memory_total_customizable: Option, + pub raw: String, + pub updated_at: String, +} + +/// 数据库管理器 +pub struct Database { + conn: Arc>, +} + +fn beijing_offset() -> FixedOffset { + FixedOffset::east_opt(BEIJING_UTC_OFFSET_SECONDS).expect("valid Beijing UTC offset") +} + +pub fn beijing_sms_now_string() -> String { + Utc::now() + .with_timezone(&beijing_offset()) + .format(SMS_TIMESTAMP_FORMAT) + .to_string() +} + +pub fn normalize_sms_timestamp_for_display(timestamp: &str) -> Option { + let timestamp = timestamp.trim(); + if timestamp.is_empty() { + return None; + } + + if let Some(parsed) = parse_sms_timestamp_with_offset(timestamp) { + return Some(parsed); + } + + for format in ["%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"] { + if let Ok(parsed) = NaiveDateTime::parse_from_str(timestamp, format) { + return Some(parsed.format(SMS_TIMESTAMP_FORMAT).to_string()); + } + } + + None +} + +fn parse_sms_timestamp_with_offset(timestamp: &str) -> Option { + let timestamp = timestamp.replace(' ', "T"); + + if let Ok(parsed) = DateTime::parse_from_rfc3339(×tamp) { + return Some( + parsed + .with_timezone(&beijing_offset()) + .format(SMS_TIMESTAMP_FORMAT) + .to_string(), + ); + } + + let offset_start = timestamp + .char_indices() + .rev() + .find_map(|(index, ch)| (index > 10 && matches!(ch, '+' | '-')).then_some(index))?; + + let (datetime, offset) = timestamp.split_at(offset_start); + let normalized_offset = match offset.len() { + 3 => format!("{offset}:00"), + 5 if !offset.contains(':') => format!("{}:{}", &offset[..3], &offset[3..]), + _ => offset.to_string(), + }; + let candidate = format!("{datetime}{normalized_offset}"); + + DateTime::parse_from_rfc3339(&candidate).ok().map(|parsed| { + parsed + .with_timezone(&beijing_offset()) + .format(SMS_TIMESTAMP_FORMAT) + .to_string() + }) +} + +fn sms_timestamp_for_storage(timestamp: &str) -> String { + normalize_sms_timestamp_for_display(timestamp).unwrap_or_else(beijing_sms_now_string) +} + +fn sms_timestamp_for_display(timestamp: String) -> String { + normalize_sms_timestamp_for_display(×tamp).unwrap_or(timestamp) +} + +fn notification_log_date_bound(value: &str, suffix: &str) -> String { + let value = value.trim().replace('/', "-"); + if value.is_empty() { + String::new() + } else if value.len() <= 10 { + format!("{value} {suffix}") + } else { + value + } +} + +fn notification_log_start_bound(value: &str) -> String { + notification_log_date_bound(value, "00:00:00") +} + +fn notification_log_end_bound(value: &str) -> String { + notification_log_date_bound(value, "23:59:59") +} + +fn sms_message_from_row(row: &Row<'_>) -> Result { + let timestamp: String = row.get(4)?; + Ok(SmsMessage { + id: row.get(0)?, + direction: row.get(1)?, + phone_number: row.get(2)?, + content: row.get(3)?, + timestamp: sms_timestamp_for_display(timestamp), + status: row.get(5)?, + pdu: row.get(6)?, + }) +} + +fn normalize_existing_sms_timestamps(conn: &Connection) -> Result<()> { + let mut stmt = conn.prepare("SELECT id, timestamp FROM sms_messages")?; + let rows = stmt.query_map([], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + })?; + + let mut updates = Vec::new(); + for row in rows { + let (id, timestamp) = row?; + if let Some(normalized) = normalize_sms_timestamp_for_display(×tamp) { + if normalized != timestamp { + updates.push((id, normalized)); + } + } + } + drop(stmt); + + for (id, timestamp) in updates { + conn.execute( + "UPDATE sms_messages SET timestamp = ?1 WHERE id = ?2", + params![timestamp, id], + )?; + } + + Ok(()) +} + +fn non_empty_option(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +fn table_has_column(conn: &Connection, table_name: &str, column_name: &str) -> Result { + let mut stmt = conn.prepare(&format!("PRAGMA table_info({table_name})"))?; + let rows = stmt.query_map([], |row| row.get::<_, String>(1))?; + + for row in rows { + if row? == column_name { + return Ok(true); + } + } + + Ok(false) +} + +impl Database { + /// 创建或打开数据库 + pub fn new(db_path: PathBuf) -> Result { + let conn = Connection::open(db_path)?; + + // 创建短信表(如果不存在) + conn.execute( + "CREATE TABLE IF NOT EXISTS sms_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + direction TEXT NOT NULL, + phone_number TEXT NOT NULL, + content TEXT NOT NULL, + timestamp TEXT NOT NULL, + status TEXT NOT NULL, + notification_status TEXT NOT NULL DEFAULT 'pending', + pdu TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )", + [], + )?; + + if !table_has_column(&conn, "sms_messages", "notification_status")? { + conn.execute( + "ALTER TABLE sms_messages + ADD COLUMN notification_status TEXT NOT NULL DEFAULT 'pending'", + [], + )?; + } + + // 创建短信索引 + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_sms_timestamp ON sms_messages(timestamp DESC)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_sms_phone ON sms_messages(phone_number)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_sms_notification_status ON sms_messages(notification_status)", + [], + )?; + normalize_existing_sms_timestamps(&conn)?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS notification_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_type TEXT NOT NULL, + status TEXT NOT NULL, + summary TEXT NOT NULL, + rule_id TEXT NOT NULL, + rule_name TEXT NOT NULL, + channel_id TEXT NOT NULL, + channel_name TEXT NOT NULL, + message TEXT NOT NULL, + created_at TEXT NOT NULL + )", + [], + )?; + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_notification_logs_created_at ON notification_logs(created_at DESC)", + [], + )?; + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_notification_logs_type_status ON notification_logs(event_type, status)", + [], + )?; + + // 创建通话记录表(如果不存在) + conn.execute( + "CREATE TABLE IF NOT EXISTS notification_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + status TEXT NOT NULL, + event_type TEXT NOT NULL, + event_label TEXT NOT NULL, + summary TEXT NOT NULL, + reason TEXT NOT NULL DEFAULT '', + rule_id TEXT NOT NULL DEFAULT '', + rule_name TEXT NOT NULL DEFAULT '', + channel_id TEXT NOT NULL, + channel_name TEXT NOT NULL, + channel_type TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + body TEXT NOT NULL DEFAULT '', + next_attempt_at TEXT NOT NULL, + attempt_count INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL DEFAULT 5, + last_error TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + expires_at TEXT NOT NULL DEFAULT '' + )", + [], + )?; + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_notification_queue_status_next_attempt + ON notification_queue(status, next_attempt_at, id)", + [], + )?; + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_notification_queue_channel_status + ON notification_queue(channel_id, status, next_attempt_at)", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS call_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + direction TEXT NOT NULL, + phone_number TEXT NOT NULL, + duration INTEGER DEFAULT 0, + start_time TEXT NOT NULL, + end_time TEXT, + answered INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )", + [], + )?; + + // 创建通话记录索引 + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_call_start_time ON call_history(start_time DESC)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_call_phone ON call_history(phone_number)", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS smsc_cache ( + identity_key TEXT PRIMARY KEY, + iccid TEXT, + imsi TEXT, + operator_id TEXT, + sms_center TEXT NOT NULL, + source TEXT NOT NULL, + updated_at TEXT NOT NULL + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS own_number_cache ( + identity_key TEXT PRIMARY KEY, + iccid TEXT, + imsi TEXT, + operator_id TEXT, + phone_numbers TEXT NOT NULL, + source TEXT NOT NULL, + updated_at TEXT NOT NULL + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS sms_storage_cache ( + identity_key TEXT PRIMARY KEY, + iccid TEXT, + imsi TEXT, + operator_id TEXT, + sms_used INTEGER, + sms_total INTEGER, + source TEXT NOT NULL, + updated_at TEXT NOT NULL + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS esim_profile_cache ( + iccid TEXT PRIMARY KEY, + name TEXT, + provider TEXT, + profile_class TEXT, + imsi TEXT, + msisdn TEXT, + smsc TEXT, + smdp TEXT, + isdp_aid TEXT, + mcc TEXT, + mnc TEXT, + updated_at TEXT NOT NULL + )", + [], + )?; + + if !table_has_column(&conn, "esim_profile_cache", "matching_id")? { + conn.execute( + "ALTER TABLE esim_profile_cache ADD COLUMN matching_id TEXT", + [], + )?; + } + if !table_has_column(&conn, "esim_profile_cache", "state")? { + conn.execute("ALTER TABLE esim_profile_cache ADD COLUMN state TEXT", [])?; + } + if !table_has_column(&conn, "esim_profile_cache", "disable_allowed")? { + conn.execute( + "ALTER TABLE esim_profile_cache ADD COLUMN disable_allowed INTEGER", + [], + )?; + } + if !table_has_column(&conn, "esim_profile_cache", "delete_allowed")? { + conn.execute( + "ALTER TABLE esim_profile_cache ADD COLUMN delete_allowed INTEGER", + [], + )?; + } + + conn.execute( + "CREATE TABLE IF NOT EXISTS esim_euicc_cache ( + cache_key TEXT PRIMARY KEY, + eid TEXT, + status TEXT, + manufacturer TEXT, + memory_total_kb REAL, + memory_available_kb REAL, + memory_total_customizable INTEGER, + raw TEXT, + updated_at TEXT NOT NULL + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS auth_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS auth_sessions ( + session_hash TEXT PRIMARY KEY, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + )", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires_at ON auth_sessions(expires_at)", + [], + )?; + + // 创建自动化运行日志表 + conn.execute( + "CREATE TABLE IF NOT EXISTS automation_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + task_name TEXT NOT NULL, + task_type TEXT NOT NULL, + status TEXT NOT NULL, + detail TEXT NOT NULL, + created_at TEXT NOT NULL + )", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_automation_logs_created_at ON automation_logs(created_at DESC)", + [], + )?; + + Ok(Self { + conn: Arc::new(Mutex::new(conn)), + }) + } + + // ==================== 认证相关方法 ==================== + + pub fn auth_is_configured(&self) -> Result { + Ok(self.get_auth_config_value("admin_password_hash")?.is_some()) + } + + pub fn get_auth_config_value(&self, key: &str) -> Result> { + let conn = self.conn.lock().unwrap(); + conn.query_row( + "SELECT value FROM auth_config WHERE key = ?1", + params![key], + |row| row.get(0), + ) + .optional() + } + + pub fn set_auth_config_value(&self, key: &str, value: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + let now = Utc::now().timestamp(); + conn.execute( + "INSERT INTO auth_config (key, value, updated_at) + VALUES (?1, ?2, ?3) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = excluded.updated_at", + params![key, value, now], + )?; + Ok(()) + } + + pub fn replace_admin_password_hash(&self, password_hash: &str) -> Result<()> { + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + let now = Utc::now().timestamp(); + tx.execute( + "INSERT INTO auth_config (key, value, updated_at) + VALUES ('admin_password_hash', ?1, ?2) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = excluded.updated_at", + params![password_hash, now], + )?; + tx.execute("DELETE FROM auth_sessions", [])?; + tx.commit()?; + Ok(()) + } + + pub fn clear_admin_auth(&self) -> Result<()> { + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + tx.execute( + "DELETE FROM auth_config WHERE key = 'admin_password_hash'", + [], + )?; + tx.execute("DELETE FROM auth_sessions", [])?; + tx.commit()?; + Ok(()) + } + + pub fn insert_auth_session(&self, session_hash: &str, ttl_seconds: i64) -> Result<()> { + let conn = self.conn.lock().unwrap(); + let now = Utc::now().timestamp(); + conn.execute( + "INSERT INTO auth_sessions (session_hash, created_at, expires_at) + VALUES (?1, ?2, ?3)", + params![session_hash, now, now + ttl_seconds], + )?; + conn.execute( + "DELETE FROM auth_sessions WHERE expires_at <= ?1", + params![now], + )?; + Ok(()) + } + + pub fn auth_session_valid(&self, session_hash: &str) -> Result { + let conn = self.conn.lock().unwrap(); + let now = Utc::now().timestamp(); + conn.execute( + "DELETE FROM auth_sessions WHERE expires_at <= ?1", + params![now], + )?; + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM auth_sessions + WHERE session_hash = ?1 AND expires_at > ?2", + params![session_hash, now], + |row| row.get(0), + )?; + Ok(count > 0) + } + + pub fn delete_auth_session(&self, session_hash: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "DELETE FROM auth_sessions WHERE session_hash = ?1", + params![session_hash], + )?; + Ok(()) + } + + pub fn refresh_auth_session(&self, session_hash: &str, ttl_seconds: i64) -> Result<()> { + let conn = self.conn.lock().unwrap(); + let now = Utc::now().timestamp(); + conn.execute( + "UPDATE auth_sessions SET expires_at = ?1 WHERE session_hash = ?2", + params![now + ttl_seconds, session_hash], + )?; + Ok(()) + } + + // ==================== 短信相关方法 ==================== + + /// 插入新短信 + pub fn insert_sms( + &self, + direction: &str, + phone_number: &str, + content: &str, + status: &str, + pdu: Option<&str>, + ) -> Result { + let timestamp = beijing_sms_now_string(); + self.insert_sms_at(direction, phone_number, content, ×tamp, status, pdu) + } + + pub fn insert_sms_at( + &self, + direction: &str, + phone_number: &str, + content: &str, + timestamp: &str, + status: &str, + pdu: Option<&str>, + ) -> Result { + let conn = self.conn.lock().unwrap(); + let timestamp = sms_timestamp_for_storage(timestamp); + conn.execute( + "INSERT INTO sms_messages (direction, phone_number, content, timestamp, status, pdu) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![direction, phone_number, content, timestamp, status, pdu], + )?; + + Ok(conn.last_insert_rowid()) + } + + /// Check whether an SMS marker has already been stored. + pub fn sms_exists_by_pdu(&self, pdu: &str) -> Result { + let conn = self.conn.lock().unwrap(); + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM sms_messages WHERE pdu = ?1", + params![pdu], + |row| row.get(0), + )?; + Ok(count > 0) + } + + pub fn incoming_sms_exists_by_timestamp( + &self, + phone_number: &str, + content: &str, + timestamp: &str, + ) -> Result { + let conn = self.conn.lock().unwrap(); + let normalized_timestamp = normalize_sms_timestamp_for_display(timestamp) + .unwrap_or_else(|| timestamp.trim().to_string()); + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM sms_messages + WHERE direction = 'incoming' + AND phone_number = ?1 + AND content = ?2 + AND (timestamp = ?3 OR timestamp = ?4)", + params![phone_number, content, timestamp, normalized_timestamp], + |row| row.get(0), + )?; + Ok(count > 0) + } + + pub fn incoming_sms_exists_by_legacy_content( + &self, + phone_number: &str, + content: &str, + ) -> Result { + let conn = self.conn.lock().unwrap(); + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM sms_messages + WHERE direction = 'incoming' + AND phone_number = ?1 + AND content = ?2 + AND (pdu IS NULL OR pdu NOT LIKE 'mmfp:%')", + params![phone_number, content], + |row| row.get(0), + )?; + Ok(count > 0) + } + + /// 获取所有短信(分页) + pub fn get_sms_messages( + &self, + limit: i64, + offset: i64, + direction: Option<&str>, + ) -> Result> { + let conn = self.conn.lock().unwrap(); + match direction { + Some(direction) => { + let mut stmt = conn.prepare( + "SELECT id, direction, phone_number, content, timestamp, status, pdu + FROM sms_messages + WHERE direction = ?1 + ORDER BY timestamp DESC, id DESC + LIMIT ?2 OFFSET ?3", + )?; + + let messages = + stmt.query_map(params![direction, limit, offset], sms_message_from_row)?; + + let mut result = Vec::new(); + for message in messages { + result.push(message?); + } + + Ok(result) + } + None => { + let mut stmt = conn.prepare( + "SELECT id, direction, phone_number, content, timestamp, status, pdu + FROM sms_messages + ORDER BY timestamp DESC, id DESC + LIMIT ?1 OFFSET ?2", + )?; + + let messages = stmt.query_map(params![limit, offset], sms_message_from_row)?; + + let mut result = Vec::new(); + for message in messages { + result.push(message?); + } + + Ok(result) + } + } + } + + /// 获取与特定号码的对话历史 + pub fn get_sms_conversation(&self, phone_number: &str, limit: i64) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, direction, phone_number, content, timestamp, status, pdu + FROM sms_messages + WHERE phone_number = ?1 + ORDER BY timestamp DESC, id DESC + LIMIT ?2", + )?; + + let messages = stmt.query_map(params![phone_number, limit], sms_message_from_row)?; + + let mut result = Vec::new(); + for message in messages { + result.push(message?); + } + + Ok(result) + } + + /// 更新短信通知转发状态:"pending", "success", "failed", "skipped" + pub fn update_sms_notification_status(&self, id: i64, status: &str) -> Result { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE sms_messages SET notification_status = ?1 WHERE id = ?2", + params![status, id], + ) + } + + /// 获取短信统计 + pub fn insert_notification_log(&self, log: NewNotificationLog<'_>) -> Result { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO notification_logs ( + event_type, status, summary, rule_id, rule_name, + channel_id, channel_name, message, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![ + log.event_type, + log.status, + log.summary, + log.rule_id, + log.rule_name, + log.channel_id, + log.channel_name, + log.message, + beijing_sms_now_string(), + ], + ) + } + + pub fn insert_notification_queue_item( + &self, + item: NewNotificationQueueItem<'_>, + ) -> Result { + let conn = self.conn.lock().unwrap(); + let now = beijing_sms_now_string(); + conn.execute( + "INSERT INTO notification_queue ( + status, event_type, event_label, summary, reason, + rule_id, rule_name, channel_id, channel_name, channel_type, + title, body, next_attempt_at, max_attempts, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?15)", + params![ + item.status, + item.event_type, + item.event_label, + item.summary, + item.reason, + item.rule_id, + item.rule_name, + item.channel_id, + item.channel_name, + item.channel_type, + item.title, + item.body, + item.next_attempt_at, + item.max_attempts, + now, + ], + )?; + Ok(conn.last_insert_rowid()) + } + + pub fn notification_channel_success_count_since( + &self, + channel_id: &str, + since: &str, + ) -> Result { + let conn = self.conn.lock().unwrap(); + conn.query_row( + "SELECT COUNT(*) FROM notification_logs + WHERE status = 'success' + AND channel_id = ?1 + AND created_at >= ?2", + params![channel_id, since], + |row| row.get(0), + ) + } + + pub fn get_notification_logs( + &self, + event_type: &str, + status: &str, + query: &str, + start_date: &str, + end_date: &str, + limit: i64, + offset: i64, + ) -> Result { + let conn = self.conn.lock().unwrap(); + let limit = limit.clamp(1, 200); + let offset = offset.max(0); + let event_type = event_type.trim(); + let status = status.trim(); + let query = query.trim(); + let start_at = notification_log_start_bound(start_date); + let end_at = notification_log_end_bound(end_date); + + let total = conn.query_row( + "SELECT COUNT(*) FROM notification_logs + WHERE (?1 = '' OR event_type = ?1) + AND (?2 = '' OR status = ?2) + AND ( + ?3 = '' + OR summary LIKE '%' || ?3 || '%' + OR rule_name LIKE '%' || ?3 || '%' + OR channel_name LIKE '%' || ?3 || '%' + OR message LIKE '%' || ?3 || '%' + ) + AND (?4 = '' OR created_at >= ?4) + AND (?5 = '' OR created_at <= ?5)", + params![event_type, status, query, start_at, end_at], + |row| row.get(0), + )?; + + let mut stmt = conn.prepare( + "SELECT id, event_type, status, summary, rule_id, rule_name, + channel_id, channel_name, message, created_at + FROM notification_logs + WHERE (?1 = '' OR event_type = ?1) + AND (?2 = '' OR status = ?2) + AND ( + ?3 = '' + OR summary LIKE '%' || ?3 || '%' + OR rule_name LIKE '%' || ?3 || '%' + OR channel_name LIKE '%' || ?3 || '%' + OR message LIKE '%' || ?3 || '%' + ) + AND (?4 = '' OR created_at >= ?4) + AND (?5 = '' OR created_at <= ?5) + ORDER BY id DESC + LIMIT ?6 OFFSET ?7", + )?; + + let rows = stmt.query_map( + params![event_type, status, query, start_at, end_at, limit, offset], + |row| { + Ok(NotificationLogEntry { + id: row.get(0)?, + event_type: row.get(1)?, + status: row.get(2)?, + summary: row.get(3)?, + rule_id: row.get(4)?, + rule_name: row.get(5)?, + channel_id: row.get(6)?, + channel_name: row.get(7)?, + message: row.get(8)?, + created_at: row.get(9)?, + }) + }, + )?; + + let mut logs = Vec::new(); + for row in rows { + logs.push(row?); + } + + Ok(NotificationLogsResponse { logs, total }) + } + + pub fn clear_notification_logs( + &self, + event_type: &str, + status: &str, + start_date: &str, + end_date: &str, + ) -> Result { + let conn = self.conn.lock().unwrap(); + let event_type = event_type.trim(); + let status = status.trim(); + let start_at = notification_log_start_bound(start_date); + let end_at = notification_log_end_bound(end_date); + conn.execute( + "DELETE FROM notification_logs + WHERE (?1 = '' OR event_type = ?1) + AND (?2 = '' OR status = ?2) + AND (?3 = '' OR created_at >= ?3) + AND (?4 = '' OR created_at <= ?4)", + params![event_type, status, start_at, end_at], + ) + } + + pub fn get_notification_queue(&self, limit: i64) -> Result { + let conn = self.conn.lock().unwrap(); + let limit = limit.clamp(1, 500); + + let total = conn.query_row( + "SELECT COUNT(*) FROM notification_queue + WHERE status IN ('pending', 'scheduled', 'retrying', 'sending', 'failed')", + [], + |row| row.get(0), + )?; + + let mut stmt = conn.prepare( + "SELECT id, status, event_type, event_label, summary, + COALESCE(NULLIF(last_error, ''), reason) AS display_reason, + channel_id, channel_name, channel_type, rule_id, rule_name, + title, body, next_attempt_at, + attempt_count, max_attempts, created_at, updated_at + FROM notification_queue + WHERE status IN ('pending', 'scheduled', 'retrying', 'sending', 'failed') + ORDER BY next_attempt_at ASC, id ASC + LIMIT ?1", + )?; + let rows = stmt.query_map(params![limit], |row| { + Ok(NotificationQueueEntry { + id: row.get(0)?, + status: row.get(1)?, + event_type: row.get(2)?, + event_label: row.get(3)?, + summary: row.get(4)?, + reason: row.get(5)?, + channel_id: row.get(6)?, + channel_name: row.get(7)?, + channel_type: row.get(8)?, + rule_id: row.get(9)?, + rule_name: row.get(10)?, + title: row.get(11)?, + body: row.get(12)?, + next_attempt_at: row.get(13)?, + attempt_count: row.get(14)?, + max_attempts: row.get(15)?, + created_at: row.get(16)?, + updated_at: row.get(17)?, + }) + })?; + + let mut items = Vec::new(); + for row in rows { + items.push(row?); + } + + Ok(NotificationQueueResponse { items, total }) + } + + pub fn retry_notification_queue_item(&self, id: i64) -> Result { + let conn = self.conn.lock().unwrap(); + let now = beijing_sms_now_string(); + conn.execute( + "UPDATE notification_queue + SET status = 'pending', + attempt_count = 0, + next_attempt_at = ?1, + last_error = '', + updated_at = ?1 + WHERE id = ?2 + AND status IN ('pending', 'scheduled', 'retrying', 'sending', 'failed')", + params![now, id], + ) + } + + pub fn delete_notification_queue_item(&self, id: i64) -> Result { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE notification_queue + SET status = 'cancelled', + updated_at = ?1 + WHERE id = ?2 + AND status IN ('pending', 'scheduled', 'retrying', 'sending', 'failed')", + params![beijing_sms_now_string(), id], + ) + } + + pub fn retry_all_notification_queue_items(&self) -> Result { + let conn = self.conn.lock().unwrap(); + let now = beijing_sms_now_string(); + conn.execute( + "UPDATE notification_queue + SET status = 'pending', + attempt_count = 0, + next_attempt_at = ?1, + last_error = '', + updated_at = ?1 + WHERE status IN ('pending', 'scheduled', 'retrying', 'sending', 'failed')", + params![now], + ) + } + + pub fn clear_active_notification_queue(&self) -> Result { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE notification_queue + SET status = 'cancelled', + updated_at = ?1 + WHERE status IN ('pending', 'scheduled', 'retrying', 'sending', 'failed')", + params![beijing_sms_now_string()], + ) + } + + pub fn get_due_notification_queue_items( + &self, + limit: i64, + ) -> Result> { + let conn = self.conn.lock().unwrap(); + let now = beijing_sms_now_string(); + let limit = limit.clamp(1, 100); + let mut stmt = conn.prepare( + "SELECT id, status, event_type, event_label, summary, + COALESCE(NULLIF(last_error, ''), reason) AS display_reason, + channel_id, channel_name, channel_type, rule_id, rule_name, + title, body, next_attempt_at, + attempt_count, max_attempts, created_at, updated_at + FROM notification_queue + WHERE status IN ('pending', 'scheduled', 'retrying') + AND next_attempt_at <= ?1 + ORDER BY next_attempt_at ASC, id ASC + LIMIT ?2", + )?; + let rows = stmt.query_map(params![now, limit], |row| { + Ok(NotificationQueueEntry { + id: row.get(0)?, + status: row.get(1)?, + event_type: row.get(2)?, + event_label: row.get(3)?, + summary: row.get(4)?, + reason: row.get(5)?, + channel_id: row.get(6)?, + channel_name: row.get(7)?, + channel_type: row.get(8)?, + rule_id: row.get(9)?, + rule_name: row.get(10)?, + title: row.get(11)?, + body: row.get(12)?, + next_attempt_at: row.get(13)?, + attempt_count: row.get(14)?, + max_attempts: row.get(15)?, + created_at: row.get(16)?, + updated_at: row.get(17)?, + }) + })?; + + let mut items = Vec::new(); + for row in rows { + items.push(row?); + } + Ok(items) + } + + pub fn mark_notification_queue_sending(&self, id: i64) -> Result { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE notification_queue + SET status = 'sending', + updated_at = ?1 + WHERE id = ?2 + AND status IN ('pending', 'scheduled', 'retrying')", + params![beijing_sms_now_string(), id], + ) + } + + pub fn mark_notification_queue_sent(&self, id: i64) -> Result { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE notification_queue + SET status = 'sent', + last_error = '', + updated_at = ?1 + WHERE id = ?2", + params![beijing_sms_now_string(), id], + ) + } + + pub fn mark_notification_queue_retry( + &self, + id: i64, + last_error: &str, + next_attempt_at: &str, + ) -> Result { + let conn = self.conn.lock().unwrap(); + let now = beijing_sms_now_string(); + conn.execute( + "UPDATE notification_queue + SET status = 'retrying', + attempt_count = attempt_count + 1, + last_error = ?1, + next_attempt_at = ?2, + updated_at = ?3 + WHERE id = ?4", + params![last_error, next_attempt_at, now, id], + ) + } + + pub fn mark_notification_queue_scheduled( + &self, + id: i64, + reason: &str, + next_attempt_at: &str, + ) -> Result { + let conn = self.conn.lock().unwrap(); + let now = beijing_sms_now_string(); + conn.execute( + "UPDATE notification_queue + SET status = 'scheduled', + reason = ?1, + next_attempt_at = ?2, + updated_at = ?3 + WHERE id = ?4", + params![reason, next_attempt_at, now, id], + ) + } + + pub fn mark_notification_queue_failed(&self, id: i64, last_error: &str) -> Result { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE notification_queue + SET status = 'failed', + attempt_count = attempt_count + 1, + last_error = ?1, + updated_at = ?2 + WHERE id = ?3", + params![last_error, beijing_sms_now_string(), id], + ) + } + + pub fn cleanup_notification_logs( + &self, + retention_days: Option, + max_entries: Option, + ) -> Result { + let conn = self.conn.lock().unwrap(); + let mut deleted = 0usize; + + if let Some(days) = retention_days.filter(|days| *days > 0) { + let cutoff = Utc::now() + .with_timezone(&beijing_offset()) + .checked_sub_signed(Duration::days(i64::from(days))) + .unwrap_or_else(|| Utc::now().with_timezone(&beijing_offset())) + .format(SMS_TIMESTAMP_FORMAT) + .to_string(); + deleted += conn.execute( + "DELETE FROM notification_logs WHERE created_at < ?1", + params![cutoff], + )?; + } + + if let Some(max_entries) = max_entries.filter(|max_entries| *max_entries > 0) { + deleted += conn.execute( + "DELETE FROM notification_logs + WHERE id NOT IN ( + SELECT id FROM notification_logs + ORDER BY id DESC + LIMIT ?1 + )", + params![i64::from(max_entries)], + )?; + } + + Ok(deleted) + } + + pub fn notification_status_counts( + &self, + event_type: &str, + since: Option<&str>, + ) -> Result { + let conn = self.conn.lock().unwrap(); + let mut counts = NotificationStatusCounts::default(); + let since = since.unwrap_or("").trim(); + let mut stmt = conn.prepare( + "SELECT status, COUNT(*) + FROM notification_logs + WHERE event_type = ?1 + AND (?2 = '' OR created_at >= ?2) + GROUP BY status", + )?; + let rows = stmt.query_map(params![event_type, since], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + })?; + for row in rows { + let (status, count) = row?; + match status.as_str() { + "success" => counts.success = count, + "failed" => counts.failed = count, + "quiet_hours" => counts.quiet_hours = count, + "unmatched" => counts.unmatched = count, + "no_available_channel" => counts.no_available_channel = count, + _ => {} + } + } + Ok(counts) + } + + pub fn period_sms_stats(&self, since: Option<&str>) -> Result { + let conn = self.conn.lock().unwrap(); + let since = since.unwrap_or("").trim(); + let incoming: i64 = conn.query_row( + "SELECT COUNT(*) FROM sms_messages + WHERE direction = 'incoming' + AND status = 'received' + AND (?1 = '' OR timestamp >= ?1)", + params![since], + |row| row.get(0), + )?; + drop(conn); + let forwarding = self.notification_status_counts("sms", Some(since))?; + Ok(PeriodSmsStats { + incoming, + forwarding, + }) + } + + pub fn get_sms_stats(&self) -> Result { + let conn = self.conn.lock().unwrap(); + + let total: i64 = + conn.query_row("SELECT COUNT(*) FROM sms_messages", [], |row| row.get(0))?; + + let incoming: i64 = conn.query_row( + "SELECT COUNT(*) FROM sms_messages + WHERE direction = 'incoming' AND status = 'received'", + [], + |row| row.get(0), + )?; + + let outgoing: i64 = conn.query_row( + "SELECT COUNT(*) FROM sms_messages WHERE direction = 'outgoing'", + [], + |row| row.get(0), + )?; + + let pushed: i64 = conn.query_row( + "SELECT COUNT(*) FROM sms_messages + WHERE direction = 'incoming' + AND status = 'received' + AND notification_status = 'success'", + [], + |row| row.get(0), + )?; + + let push_attempted: i64 = conn.query_row( + "SELECT COUNT(*) FROM sms_messages + WHERE direction = 'incoming' + AND status = 'received' + AND notification_status IN ('success', 'failed')", + [], + |row| row.get(0), + )?; + + Ok(SmsStats { + total, + incoming, + outgoing, + pushed, + push_attempted, + }) + } + + /// 删除所有短信 + pub fn clear_all_sms(&self) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM sms_messages", [])?; + Ok(()) + } + + /// 删除单条短信 + pub fn delete_sms(&self, id: i64) -> Result { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM sms_messages WHERE id = ?1", params![id]) + } + + /// 删除一个对话的所有短信 + pub fn delete_sms_conversation(&self, phone_number: &str) -> Result { + let conn = self.conn.lock().unwrap(); + conn.execute( + "DELETE FROM sms_messages WHERE phone_number = ?1", + params![phone_number], + ) + } + + /// 按短信 ID 和对话号码批量删除 + pub fn delete_sms_batch(&self, ids: &[i64], phone_numbers: &[String]) -> Result { + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + let mut deleted = 0usize; + + for phone_number in phone_numbers { + deleted += tx.execute( + "DELETE FROM sms_messages WHERE phone_number = ?1", + params![phone_number], + )?; + } + + for id in ids { + deleted += tx.execute("DELETE FROM sms_messages WHERE id = ?1", params![id])?; + } + + tx.commit()?; + Ok(deleted) + } + + pub fn vacuum(&self) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute_batch("VACUUM")?; + Ok(()) + } + + // ==================== SMSC cache ==================== + + pub fn upsert_smsc_cache( + &self, + identity_key: &str, + iccid: &str, + imsi: &str, + operator_id: &str, + sms_center: &str, + source: &str, + ) -> Result<()> { + let conn = self.conn.lock().unwrap(); + let updated_at = Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO smsc_cache ( + identity_key, iccid, imsi, operator_id, sms_center, source, updated_at + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(identity_key) DO UPDATE SET + iccid = excluded.iccid, + imsi = excluded.imsi, + operator_id = excluded.operator_id, + sms_center = excluded.sms_center, + source = excluded.source, + updated_at = excluded.updated_at", + params![ + identity_key, + iccid, + imsi, + operator_id, + sms_center, + source, + updated_at + ], + )?; + Ok(()) + } + + pub fn get_smsc_cache(&self, identity_keys: &[String]) -> Result> { + let conn = self.conn.lock().unwrap(); + for key in identity_keys { + let entry = conn + .query_row( + "SELECT sms_center, source, updated_at + FROM smsc_cache + WHERE identity_key = ?1", + params![key], + |row| { + Ok(SmscCacheEntry { + sms_center: row.get(0)?, + source: row.get(1)?, + updated_at: row.get(2)?, + }) + }, + ) + .optional()?; + if entry.is_some() { + return Ok(entry); + } + } + Ok(None) + } + + // ==================== Own number cache ==================== + + pub fn upsert_own_number_cache( + &self, + identity_key: &str, + iccid: &str, + imsi: &str, + operator_id: &str, + phone_numbers: &[String], + source: &str, + ) -> Result<()> { + let conn = self.conn.lock().unwrap(); + let updated_at = Utc::now().to_rfc3339(); + let phone_numbers = phone_numbers.join("\n"); + conn.execute( + "INSERT INTO own_number_cache ( + identity_key, iccid, imsi, operator_id, phone_numbers, source, updated_at + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(identity_key) DO UPDATE SET + iccid = excluded.iccid, + imsi = excluded.imsi, + operator_id = excluded.operator_id, + phone_numbers = excluded.phone_numbers, + source = excluded.source, + updated_at = excluded.updated_at", + params![ + identity_key, + iccid, + imsi, + operator_id, + phone_numbers, + source, + updated_at + ], + )?; + Ok(()) + } + + pub fn get_own_number_cache( + &self, + identity_keys: &[String], + ) -> Result> { + let conn = self.conn.lock().unwrap(); + for key in identity_keys { + let entry = conn + .query_row( + "SELECT phone_numbers, source, updated_at + FROM own_number_cache + WHERE identity_key = ?1", + params![key], + |row| { + let phone_numbers: String = row.get(0)?; + Ok(OwnNumberCacheEntry { + phone_numbers: phone_numbers + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(ToString::to_string) + .collect(), + source: row.get(1)?, + updated_at: row.get(2)?, + }) + }, + ) + .optional()?; + if entry.is_some() { + return Ok(entry); + } + } + Ok(None) + } + + // ==================== SMS storage cache ==================== + + pub fn upsert_sms_storage_cache( + &self, + identity_key: &str, + iccid: &str, + imsi: &str, + operator_id: &str, + sms_used: Option, + sms_total: Option, + source: &str, + ) -> Result<()> { + let conn = self.conn.lock().unwrap(); + let updated_at = Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO sms_storage_cache ( + identity_key, iccid, imsi, operator_id, sms_used, sms_total, source, updated_at + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT(identity_key) DO UPDATE SET + iccid = excluded.iccid, + imsi = excluded.imsi, + operator_id = excluded.operator_id, + sms_used = excluded.sms_used, + sms_total = COALESCE(excluded.sms_total, sms_storage_cache.sms_total), + source = excluded.source, + updated_at = excluded.updated_at", + params![ + identity_key, + iccid, + imsi, + operator_id, + sms_used, + sms_total, + source, + updated_at + ], + )?; + Ok(()) + } + + pub fn get_sms_storage_cache( + &self, + identity_keys: &[String], + ) -> Result> { + let conn = self.conn.lock().unwrap(); + for key in identity_keys { + let entry = conn + .query_row( + "SELECT sms_used, sms_total, source, updated_at + FROM sms_storage_cache + WHERE identity_key = ?1", + params![key], + |row| { + Ok(SmsStorageCacheEntry { + sms_used: row.get(0)?, + sms_total: row.get(1)?, + source: row.get(2)?, + updated_at: row.get(3)?, + }) + }, + ) + .optional()?; + if entry.is_some() { + return Ok(entry); + } + } + Ok(None) + } + + // ==================== eSIM Profile cache ==================== + + pub fn upsert_esim_profile_cache(&self, entry: &EsimProfileCacheEntry) -> Result<()> { + let iccid = crate::utils::normalize_iccid(&entry.iccid); + if iccid.is_empty() { + return Ok(()); + } + + let has_profile_data = [ + entry.name.as_deref(), + entry.provider.as_deref(), + entry.state.as_deref(), + entry.profile_class.as_deref(), + entry.imsi.as_deref(), + entry.msisdn.as_deref(), + entry.smsc.as_deref(), + entry.smdp.as_deref(), + entry.matching_id.as_deref(), + entry.isdp_aid.as_deref(), + entry.mcc.as_deref(), + entry.mnc.as_deref(), + ] + .into_iter() + .flatten() + .any(|value| !value.trim().is_empty()); + + if !has_profile_data { + return Ok(()); + } + + let conn = self.conn.lock().unwrap(); + let updated_at = Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO esim_profile_cache ( + iccid, name, provider, state, profile_class, imsi, msisdn, smsc, smdp, + matching_id, isdp_aid, mcc, mnc, disable_allowed, delete_allowed, updated_at + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16) + ON CONFLICT(iccid) DO UPDATE SET + name = COALESCE(excluded.name, esim_profile_cache.name), + provider = COALESCE(excluded.provider, esim_profile_cache.provider), + state = COALESCE(excluded.state, esim_profile_cache.state), + profile_class = COALESCE(excluded.profile_class, esim_profile_cache.profile_class), + imsi = COALESCE(excluded.imsi, esim_profile_cache.imsi), + msisdn = COALESCE(excluded.msisdn, esim_profile_cache.msisdn), + smsc = COALESCE(excluded.smsc, esim_profile_cache.smsc), + smdp = COALESCE(excluded.smdp, esim_profile_cache.smdp), + matching_id = COALESCE(excluded.matching_id, esim_profile_cache.matching_id), + isdp_aid = COALESCE(excluded.isdp_aid, esim_profile_cache.isdp_aid), + mcc = COALESCE(excluded.mcc, esim_profile_cache.mcc), + mnc = COALESCE(excluded.mnc, esim_profile_cache.mnc), + disable_allowed = COALESCE(excluded.disable_allowed, esim_profile_cache.disable_allowed), + delete_allowed = COALESCE(excluded.delete_allowed, esim_profile_cache.delete_allowed), + updated_at = excluded.updated_at", + params![ + &iccid, + non_empty_option(entry.name.as_deref()), + non_empty_option(entry.provider.as_deref()), + non_empty_option(entry.state.as_deref()), + non_empty_option(entry.profile_class.as_deref()), + non_empty_option(entry.imsi.as_deref()), + non_empty_option(entry.msisdn.as_deref()), + non_empty_option(entry.smsc.as_deref()), + non_empty_option(entry.smdp.as_deref()), + non_empty_option(entry.matching_id.as_deref()), + non_empty_option(entry.isdp_aid.as_deref()), + non_empty_option(entry.mcc.as_deref()), + non_empty_option(entry.mnc.as_deref()), + entry.disable_allowed, + entry.delete_allowed, + updated_at + ], + )?; + Ok(()) + } + + pub fn get_esim_profile_cache(&self, iccid: &str) -> Result> { + let iccid = crate::utils::normalize_iccid(iccid); + if iccid.is_empty() { + return Ok(None); + } + + let conn = self.conn.lock().unwrap(); + conn.query_row( + "SELECT iccid, name, provider, state, profile_class, imsi, msisdn, smsc, smdp, + matching_id, isdp_aid, mcc, mnc, disable_allowed, delete_allowed, updated_at + FROM esim_profile_cache + WHERE iccid = ?1", + params![&iccid], + |row| { + Ok(EsimProfileCacheEntry { + iccid: row.get(0)?, + name: row.get(1)?, + provider: row.get(2)?, + state: row.get(3)?, + profile_class: row.get(4)?, + imsi: row.get(5)?, + msisdn: row.get(6)?, + smsc: row.get(7)?, + smdp: row.get(8)?, + matching_id: row.get(9)?, + isdp_aid: row.get(10)?, + mcc: row.get(11)?, + mnc: row.get(12)?, + disable_allowed: row.get(13)?, + delete_allowed: row.get(14)?, + updated_at: row.get(15)?, + }) + }, + ) + .optional() + } + + pub fn list_esim_profile_cache(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut statement = conn.prepare( + "SELECT iccid, name, provider, state, profile_class, imsi, msisdn, smsc, smdp, + matching_id, isdp_aid, mcc, mnc, disable_allowed, delete_allowed, updated_at + FROM esim_profile_cache + ORDER BY iccid ASC", + )?; + let rows = statement.query_map([], |row| { + Ok(EsimProfileCacheEntry { + iccid: row.get(0)?, + name: row.get(1)?, + provider: row.get(2)?, + state: row.get(3)?, + profile_class: row.get(4)?, + imsi: row.get(5)?, + msisdn: row.get(6)?, + smsc: row.get(7)?, + smdp: row.get(8)?, + matching_id: row.get(9)?, + isdp_aid: row.get(10)?, + mcc: row.get(11)?, + mnc: row.get(12)?, + disable_allowed: row.get(13)?, + delete_allowed: row.get(14)?, + updated_at: row.get(15)?, + }) + })?; + + rows.collect() + } + + pub fn delete_esim_profile_cache(&self, iccid: &str) -> Result<()> { + let iccid = crate::utils::normalize_iccid(iccid); + let conn = self.conn.lock().unwrap(); + conn.execute( + "DELETE FROM esim_profile_cache WHERE iccid = ?1", + params![&iccid], + )?; + Ok(()) + } + + // ==================== eUICC cache ==================== + + pub fn upsert_esim_euicc_cache(&self, entry: &EsimEuiccCacheEntry) -> Result<()> { + let cache_key = if entry.cache_key.trim().is_empty() { + if entry.eid.trim().is_empty() { + "default".to_string() + } else { + format!("eid:{}", entry.eid.trim()) + } + } else { + entry.cache_key.trim().to_string() + }; + let conn = self.conn.lock().unwrap(); + let updated_at = Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO esim_euicc_cache ( + cache_key, eid, status, manufacturer, memory_total_kb, + memory_available_kb, memory_total_customizable, raw, updated_at + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(cache_key) DO UPDATE SET + eid = excluded.eid, + status = excluded.status, + manufacturer = excluded.manufacturer, + memory_total_kb = excluded.memory_total_kb, + memory_available_kb = excluded.memory_available_kb, + memory_total_customizable = excluded.memory_total_customizable, + raw = excluded.raw, + updated_at = excluded.updated_at", + params![ + cache_key, + entry.eid, + entry.status, + entry.manufacturer, + entry.memory_total_kb, + entry.memory_available_kb, + entry.memory_total_customizable, + entry.raw, + updated_at + ], + )?; + Ok(()) + } + + pub fn get_esim_euicc_cache(&self, cache_key: &str) -> Result> { + let conn = self.conn.lock().unwrap(); + conn.query_row( + "SELECT cache_key, eid, status, manufacturer, memory_total_kb, + memory_available_kb, memory_total_customizable, raw, updated_at + FROM esim_euicc_cache + WHERE cache_key = ?1", + params![cache_key], + |row| { + Ok(EsimEuiccCacheEntry { + cache_key: row.get(0)?, + eid: row.get(1)?, + status: row.get(2)?, + manufacturer: row.get(3)?, + memory_total_kb: row.get(4)?, + memory_available_kb: row.get(5)?, + memory_total_customizable: row.get(6)?, + raw: row.get(7)?, + updated_at: row.get(8)?, + }) + }, + ) + .optional() + } + + pub fn latest_esim_euicc_cache(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + conn.query_row( + "SELECT cache_key, eid, status, manufacturer, memory_total_kb, + memory_available_kb, memory_total_customizable, raw, updated_at + FROM esim_euicc_cache + ORDER BY updated_at DESC + LIMIT 1", + [], + |row| { + Ok(EsimEuiccCacheEntry { + cache_key: row.get(0)?, + eid: row.get(1)?, + status: row.get(2)?, + manufacturer: row.get(3)?, + memory_total_kb: row.get(4)?, + memory_available_kb: row.get(5)?, + memory_total_customizable: row.get(6)?, + raw: row.get(7)?, + updated_at: row.get(8)?, + }) + }, + ) + .optional() + } + + // ==================== 通话记录相关方法 ==================== + + /// 插入新通话记录 + pub fn insert_call(&self, direction: &str, phone_number: &str, answered: bool) -> Result { + let conn = self.conn.lock().unwrap(); + let start_time = Utc::now().to_rfc3339(); + + conn.execute( + "INSERT INTO call_history (direction, phone_number, duration, start_time, answered) + VALUES (?1, ?2, 0, ?3, ?4)", + params![direction, phone_number, start_time, answered as i32], + )?; + + Ok(conn.last_insert_rowid()) + } + + /// 更新通话记录(通话结束时调用) + pub fn update_call_end(&self, id: i64, duration: i64, answered: bool) -> Result<()> { + let conn = self.conn.lock().unwrap(); + let end_time = Utc::now().to_rfc3339(); + + conn.execute( + "UPDATE call_history SET duration = ?1, end_time = ?2, answered = ?3 WHERE id = ?4", + params![duration, end_time, answered as i32, id], + )?; + Ok(()) + } + + /// 标记通话为未接来电 + pub fn mark_call_missed(&self, id: i64) -> Result<()> { + let conn = self.conn.lock().unwrap(); + let end_time = Utc::now().to_rfc3339(); + + conn.execute( + "UPDATE call_history SET direction = 'missed', end_time = ?1, answered = 0 WHERE id = ?2", + params![end_time, id], + )?; + Ok(()) + } + + /// 获取通话记录(分页) + pub fn get_call_history(&self, limit: i64, offset: i64) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, direction, phone_number, duration, start_time, end_time, answered + FROM call_history + ORDER BY start_time DESC + LIMIT ?1 OFFSET ?2", + )?; + + let records = stmt.query_map(params![limit, offset], |row| { + Ok(CallRecord { + id: row.get(0)?, + direction: row.get(1)?, + phone_number: row.get(2)?, + duration: row.get(3)?, + start_time: row.get(4)?, + end_time: row.get(5)?, + answered: row.get::<_, i32>(6)? != 0, + }) + })?; + + let mut result = Vec::new(); + for record in records { + result.push(record?); + } + + Ok(result) + } + + /// 获取通话统计 + pub fn get_call_stats(&self) -> Result { + let conn = self.conn.lock().unwrap(); + + let total: i64 = + conn.query_row("SELECT COUNT(*) FROM call_history", [], |row| row.get(0))?; + + let incoming: i64 = conn.query_row( + "SELECT COUNT(*) FROM call_history WHERE direction = 'incoming'", + [], + |row| row.get(0), + )?; + + let outgoing: i64 = conn.query_row( + "SELECT COUNT(*) FROM call_history WHERE direction = 'outgoing'", + [], + |row| row.get(0), + )?; + + let missed: i64 = conn.query_row( + "SELECT COUNT(*) FROM call_history WHERE direction = 'missed'", + [], + |row| row.get(0), + )?; + + let total_duration: i64 = conn.query_row( + "SELECT COALESCE(SUM(duration), 0) FROM call_history WHERE answered = 1", + [], + |row| row.get(0), + )?; + + Ok(CallStats { + total, + incoming, + outgoing, + missed, + total_duration, + }) + } + + /// 删除单条通话记录 + pub fn delete_call(&self, id: i64) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM call_history WHERE id = ?1", params![id])?; + Ok(()) + } + + /// 删除所有通话记录 + pub fn clear_all_calls(&self) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM call_history", [])?; + Ok(()) + } + + // ==================== 自动化运行日志相关方法 ==================== + + /// 插入新自动化执行日志 + pub fn insert_automation_log( + &self, + task_id: &str, + task_name: &str, + task_type: &str, + status: &str, + detail: &str, + ) -> Result { + let conn = self.conn.lock().unwrap(); + let created_at = beijing_sms_now_string(); + conn.execute( + "INSERT INTO automation_logs (task_id, task_name, task_type, status, detail, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![task_id, task_name, task_type, status, detail, created_at], + )?; + Ok(conn.last_insert_rowid()) + } + + /// 获取自动化执行日志(分页与过滤) + pub fn get_automation_logs( + &self, + task_type: &str, + status: &str, + query: &str, + start_date: &str, + end_date: &str, + limit: i64, + offset: i64, + ) -> Result { + let conn = self.conn.lock().unwrap(); + let limit = limit.clamp(1, 200); + let offset = offset.max(0); + let task_type = task_type.trim(); + let status = status.trim(); + let query = query.trim(); + + let start_at = notification_log_start_bound(start_date); + let end_at = notification_log_end_bound(end_date); + + let total = conn.query_row( + "SELECT COUNT(*) FROM automation_logs + WHERE (?1 = '' OR task_type = ?1) + AND (?2 = '' OR status = ?2) + AND ( + ?3 = '' + OR task_name LIKE '%' || ?3 || '%' + OR detail LIKE '%' || ?3 || '%' + ) + AND (?4 = '' OR created_at >= ?4) + AND (?5 = '' OR created_at <= ?5)", + params![task_type, status, query, start_at, end_at], + |row| row.get(0), + )?; + + let mut stmt = conn.prepare( + "SELECT id, task_id, task_name, task_type, status, detail, created_at + FROM automation_logs + WHERE (?1 = '' OR task_type = ?1) + AND (?2 = '' OR status = ?2) + AND ( + ?3 = '' + OR task_name LIKE '%' || ?3 || '%' + OR detail LIKE '%' || ?3 || '%' + ) + AND (?4 = '' OR created_at >= ?4) + AND (?5 = '' OR created_at <= ?5) + ORDER BY created_at DESC + LIMIT ?6 OFFSET ?7", + )?; + + let rows = stmt.query_map( + params![task_type, status, query, start_at, end_at, limit, offset], + |row| { + let mut detail: String = row.get(5)?; + if detail == "执行成功 (0)" || detail.starts_with("执行成功 (0)") { + detail = "执行成功".to_string(); + } + Ok(AutomationLogEntry { + id: row.get(0)?, + task_id: row.get(1)?, + task_name: row.get(2)?, + task_type: row.get(3)?, + status: row.get(4)?, + detail, + created_at: row.get(6)?, + }) + }, + )?; + + let mut logs = Vec::new(); + for row in rows { + logs.push(row?); + } + + Ok(AutomationLogsResponse { logs, total }) + } + + /// 获取特定任务的最后一次运行日志 + pub fn get_last_log_for_task(&self, task_id: &str) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, task_id, task_name, task_type, status, detail, created_at + FROM automation_logs + WHERE task_id = ?1 + ORDER BY created_at DESC + LIMIT 1", + )?; + let mut rows = stmt.query_map(params![task_id], |row| { + let mut detail: String = row.get(5)?; + if detail == "执行成功 (0)" || detail.starts_with("执行成功 (0)") { + detail = "执行成功".to_string(); + } + Ok(AutomationLogEntry { + id: row.get(0)?, + task_id: row.get(1)?, + task_name: row.get(2)?, + task_type: row.get(3)?, + status: row.get(4)?, + detail, + created_at: row.get(6)?, + }) + })?; + if let Some(row) = rows.next() { + Ok(Some(row?)) + } else { + Ok(None) + } + } + + /// 清理过滤的日志 + pub fn clear_automation_logs( + &self, + task_type: &str, + status: &str, + start_date: &str, + end_date: &str, + ) -> Result { + let conn = self.conn.lock().unwrap(); + let task_type = task_type.trim(); + let status = status.trim(); + + let start_at = notification_log_start_bound(start_date); + let end_at = notification_log_end_bound(end_date); + + conn.execute( + "DELETE FROM automation_logs + WHERE (?1 = '' OR task_type = ?1) + AND (?2 = '' OR status = ?2) + AND (?3 = '' OR created_at >= ?3) + AND (?4 = '' OR created_at <= ?4)", + params![task_type, status, start_at, end_at], + ) + } + + /// 自动保留策略清理 + pub fn cleanup_automation_logs( + &self, + retention_days: Option, + max_entries: Option, + ) -> Result { + let conn = self.conn.lock().unwrap(); + let mut deleted = 0usize; + + if let Some(days) = retention_days.filter(|days| *days > 0) { + let cutoff = Utc::now() + .with_timezone(&beijing_offset()) + .checked_sub_signed(Duration::days(i64::from(days))) + .unwrap_or_else(|| Utc::now().with_timezone(&beijing_offset())) + .format(SMS_TIMESTAMP_FORMAT) + .to_string(); + deleted += conn.execute( + "DELETE FROM automation_logs WHERE created_at < ?1", + params![cutoff], + )?; + } + + if let Some(max_entries) = max_entries.filter(|max_entries| *max_entries > 0) { + deleted += conn.execute( + "DELETE FROM automation_logs + WHERE id NOT IN ( + SELECT id FROM automation_logs + ORDER BY id DESC + LIMIT ?1 + )", + params![i64::from(max_entries)], + )?; + } + + Ok(deleted) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_db() -> Database { + Database::new(PathBuf::from(":memory:")).unwrap() + } + + #[test] + fn own_number_cache_allows_empty_result() { + let db = test_db(); + + db.upsert_own_number_cache( + "iccid:TEST_ICCID_001", + "TEST_ICCID_001", + "001010", + "00101", + &[], + "empty", + ) + .unwrap(); + + let entry = db + .get_own_number_cache(&["iccid:TEST_ICCID_001".to_string()]) + .unwrap() + .unwrap(); + assert!(entry.phone_numbers.is_empty()); + assert_eq!(entry.source, "empty"); + assert!(!entry.updated_at.is_empty()); + } + + #[test] + fn sms_storage_cache_allows_empty_result() { + let db = test_db(); + + db.upsert_sms_storage_cache( + "iccid:TEST_ICCID_001", + "TEST_ICCID_001", + "001010", + "00101", + None, + None, + "empty", + ) + .unwrap(); + + let entry = db + .get_sms_storage_cache(&["iccid:TEST_ICCID_001".to_string()]) + .unwrap() + .unwrap(); + assert_eq!(entry.sms_used, None); + assert_eq!(entry.sms_total, None); + assert_eq!(entry.source, "empty"); + assert!(!entry.updated_at.is_empty()); + } + + #[test] + fn esim_profile_cache_persists_state_permissions_and_updated_at() { + let db = test_db(); + db.upsert_esim_profile_cache(&EsimProfileCacheEntry { + iccid: "8901000000000000001".to_string(), + name: Some("Profile A".to_string()), + provider: Some("Provider".to_string()), + state: Some("enabled".to_string()), + disable_allowed: Some(false), + delete_allowed: Some(true), + ..Default::default() + }) + .unwrap(); + + let entry = db + .get_esim_profile_cache("8901000000000000001") + .unwrap() + .unwrap(); + assert_eq!(entry.state.as_deref(), Some("enabled")); + assert_eq!(entry.disable_allowed, Some(false)); + assert_eq!(entry.delete_allowed, Some(true)); + assert!(!entry.updated_at.is_empty()); + + let listed = db.list_esim_profile_cache().unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].state.as_deref(), Some("enabled")); + assert_eq!(listed[0].disable_allowed, Some(false)); + assert_eq!(listed[0].delete_allowed, Some(true)); + } + + #[test] + fn esim_euicc_cache_persists_latest_snapshot() { + let db = test_db(); + db.upsert_esim_euicc_cache(&EsimEuiccCacheEntry { + cache_key: "eid:EID001".to_string(), + eid: "EID001".to_string(), + status: "ready".to_string(), + manufacturer: "Test".to_string(), + memory_total_kb: Some(1024.0), + memory_available_kb: Some(512.0), + memory_total_customizable: Some(true), + raw: "{}".to_string(), + updated_at: String::new(), + }) + .unwrap(); + + let by_key = db.get_esim_euicc_cache("eid:EID001").unwrap().unwrap(); + assert_eq!(by_key.eid, "EID001"); + assert_eq!(by_key.memory_total_kb, Some(1024.0)); + assert_eq!(by_key.memory_available_kb, Some(512.0)); + assert_eq!(by_key.memory_total_customizable, Some(true)); + assert!(!by_key.updated_at.is_empty()); + + let latest = db.latest_esim_euicc_cache().unwrap().unwrap(); + assert_eq!(latest.cache_key, "eid:EID001"); + } +} diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/backend/src/device_network.rs b/packages/operation-registry/test/fixtures/upstream-58e2204/backend/src/device_network.rs new file mode 100644 index 0000000..44c6278 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/backend/src/device_network.rs @@ -0,0 +1,1605 @@ +//! Device-side network features: DDNS and WLAN client management. + +use std::collections::{BTreeMap, VecDeque}; +use std::net::IpAddr; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use base64::{engine::general_purpose, Engine as _}; +use chrono::{Duration as ChronoDuration, SecondsFormat, Utc}; +use reqwest::Client; +use ring::{digest, hmac}; +use serde_json::{json, Value as JsonValue}; +use tokio::sync::Mutex; + +use crate::config::{ConfigManager, DdnsConfig, DdnsIpConfig}; +use crate::models::{ + DdnsEvent, DdnsLogEntry, DdnsLogsResponse, DdnsRecordSyncResult, DdnsStatusResponse, + DdnsSyncResponse, WlanConnectRequest, WlanEnabledRequest, WlanForgetRequest, WlanNetwork, + WlanProfileRequest, WlanProfilesResponse, WlanSavedNetwork, WlanScanResponse, + WlanStatusResponse, +}; +use crate::notification::NotificationSender; +use crate::utils::{ + interface_addresses_for_family, preferred_interface_for_family, read_network_interfaces, + NetworkAddressFamily, +}; + +const DDNS_LOG_LIMIT: usize = 50; +const WLAN_ROUTE_METRIC: &str = "100"; + +#[derive(Debug, Default)] +struct DdnsRuntimeState { + running: bool, + last_sync_at: Option, + last_ipv4: Option, + last_ipv6: Option, + last_message: Option, + logs: VecDeque, + failure_counts: BTreeMap, +} + +#[derive(Clone)] +pub struct DdnsManager { + client: Client, + state: Arc>, +} + +impl DdnsManager { + pub fn new() -> Self { + Self { + client: Client::builder() + .timeout(Duration::from_secs(20)) + .user_agent("SimAdmin DDNS") + .build() + .expect("Failed to create DDNS HTTP client"), + state: Arc::new(Mutex::new(DdnsRuntimeState::default())), + } + } + + pub async fn status(&self, config: &DdnsConfig) -> DdnsStatusResponse { + let state = self.state.lock().await; + DdnsStatusResponse { + enabled: config.enabled, + running: state.running, + provider: config.provider.clone(), + last_sync_at: state.last_sync_at.clone(), + last_ipv4: state.last_ipv4.clone(), + last_ipv6: state.last_ipv6.clone(), + last_message: state.last_message.clone(), + } + } + + pub async fn logs(&self) -> DdnsLogsResponse { + let state = self.state.lock().await; + DdnsLogsResponse { + entries: state.logs.iter().cloned().collect(), + } + } + + pub async fn clear_logs(&self) { + let mut state = self.state.lock().await; + state.logs.clear(); + } + + pub async fn sync_now( + &self, + config_manager: Arc, + notification_sender: Arc, + ) -> Result { + { + let mut state = self.state.lock().await; + if state.running { + return Err("DDNS sync is already running".to_string()); + } + state.running = true; + } + + let started_at = now_string(); + let config = config_manager.get_ddns_config(); + let result = self.sync_inner(&config, notification_sender).await; + let finished_at = now_string(); + + let mut state = self.state.lock().await; + state.running = false; + state.last_sync_at = Some(finished_at.clone()); + + match result { + Ok(records) => { + for record in &records { + if record.record_type == "A" && record.new_ip.is_some() { + state.last_ipv4 = record.new_ip.clone(); + } + if record.record_type == "AAAA" && record.new_ip.is_some() { + state.last_ipv6 = record.new_ip.clone(); + } + } + let message = records + .last() + .map(|record| record.message.clone()) + .unwrap_or_else(|| "DDNS disabled or no enabled records".to_string()); + state.last_message = Some(message); + Ok(DdnsSyncResponse { + started_at, + finished_at, + records, + }) + } + Err(err) => { + state.last_message = Some(err.clone()); + Err(err) + } + } + } + + async fn sync_inner( + &self, + config: &DdnsConfig, + notification_sender: Arc, + ) -> Result, String> { + if !config.enabled { + self.push_log("info", "", Vec::new(), "DDNS is disabled") + .await; + return Ok(Vec::new()); + } + + let mut records = Vec::new(); + if config.ipv4.enabled { + records.push( + self.sync_record(config, &config.ipv4, "A", notification_sender.clone()) + .await, + ); + } + if config.ipv6.enabled { + records.push( + self.sync_record(config, &config.ipv6, "AAAA", notification_sender.clone()) + .await, + ); + } + Ok(records) + } + + async fn sync_record( + &self, + config: &DdnsConfig, + ip_config: &DdnsIpConfig, + record_type: &str, + notification_sender: Arc, + ) -> DdnsRecordSyncResult { + let domains = normalize_domains(&ip_config.domains); + if domains.is_empty() { + let result = DdnsRecordSyncResult { + record_type: record_type.to_string(), + domains, + status: "skipped".to_string(), + message: format!("{record_type} has no domains configured"), + ..DdnsRecordSyncResult::default() + }; + self.push_log("info", record_type, result.domains.clone(), &result.message) + .await; + return result; + } + + let ip = match self.get_ip(ip_config, record_type).await { + Ok(ip) => ip, + Err(err) => { + let result = DdnsRecordSyncResult { + record_type: record_type.to_string(), + domains: domains.clone(), + status: "failed".to_string(), + message: format!("Failed to get {record_type} address: {err}"), + ..DdnsRecordSyncResult::default() + }; + self.push_log("error", record_type, domains.clone(), &result.message) + .await; + let failure_count = self + .update_failure_count(config, record_type, &domains, true) + .await; + self.notify(config, &result, failure_count, notification_sender) + .await; + return result; + } + }; + + let update = self + .update_provider_records(config, record_type, &domains, &ip) + .await; + let result = match update { + Ok(summary) => { + let status = if summary.changed { + "updated" + } else { + "unchanged" + }; + DdnsRecordSyncResult { + record_type: record_type.to_string(), + domains: domains.clone(), + old_ip: summary.old_ip, + new_ip: Some(ip), + status: status.to_string(), + message: summary.message, + } + } + Err(err) => DdnsRecordSyncResult { + record_type: record_type.to_string(), + domains: domains.clone(), + new_ip: Some(ip), + status: "failed".to_string(), + message: err, + ..DdnsRecordSyncResult::default() + }, + }; + + let log_level = if result.status == "failed" { + "error" + } else { + "info" + }; + self.push_log(log_level, record_type, domains, &result.message) + .await; + let failure_count = self + .update_failure_count( + config, + record_type, + &result.domains, + result.status == "failed", + ) + .await; + if matches!(result.status.as_str(), "updated" | "failed") { + self.notify(config, &result, failure_count, notification_sender) + .await; + } + result + } + + async fn update_failure_count( + &self, + config: &DdnsConfig, + record_type: &str, + domains: &[String], + failed: bool, + ) -> u32 { + let key = ddns_failure_key(&config.provider, record_type, domains); + let mut state = self.state.lock().await; + if failed { + let count = state.failure_counts.entry(key).or_insert(0); + *count = count.saturating_add(1); + *count + } else { + state.failure_counts.remove(&key); + 0 + } + } + + async fn notify( + &self, + config: &DdnsConfig, + result: &DdnsRecordSyncResult, + failure_count: u32, + notification_sender: Arc, + ) { + let event = DdnsEvent { + provider: config.provider.clone(), + record_type: result.record_type.clone(), + domains: result.domains.clone(), + old_ip: result.old_ip.clone(), + new_ip: result.new_ip.clone(), + status: result.status.clone(), + message: result.message.clone(), + timestamp: now_string(), + failure_count, + }; + if notification_sender.ddns_event_blocked_by_failure_threshold(&event) { + return; + } + if let Err(err) = notification_sender.forward_ddns_event(&event).await { + self.push_log( + "warn", + &result.record_type, + result.domains.clone(), + &format!("DDNS notification failed: {err}"), + ) + .await; + } + } + + async fn push_log(&self, level: &str, record_type: &str, domains: Vec, message: &str) { + let mut state = self.state.lock().await; + if state.logs.len() >= DDNS_LOG_LIMIT { + state.logs.pop_front(); + } + state.logs.push_back(DdnsLogEntry { + timestamp: beijing_time_string(), + level: level.to_string(), + record_type: record_type.to_string(), + domains, + message: message.to_string(), + }); + } + + async fn get_ip(&self, ip_config: &DdnsIpConfig, record_type: &str) -> Result { + match ip_config.get_type.as_str() { + "interface" => get_ip_from_interface(ip_config, record_type).await, + "api" | "" => self.get_ip_from_api(ip_config, record_type).await, + other => Err(format!("unsupported IP source: {other}")), + } + } + + async fn get_ip_from_api( + &self, + ip_config: &DdnsIpConfig, + record_type: &str, + ) -> Result { + let urls = if ip_config.urls.is_empty() { + default_urls_for_record(record_type) + } else { + ip_config.urls.clone() + }; + let mut last_error = String::new(); + for url in urls { + match self.client.get(url.trim()).send().await { + Ok(response) => match response.text().await { + Ok(body) => { + if let Some(ip) = extract_ip_from_text(&body, record_type) { + return Ok(ip); + } + last_error = "response did not contain a valid IP address".to_string(); + } + Err(err) => last_error = err.to_string(), + }, + Err(err) => last_error = err.to_string(), + } + } + Err(last_error) + } + + async fn update_provider_records( + &self, + config: &DdnsConfig, + record_type: &str, + domains: &[String], + ip: &str, + ) -> Result { + match config.provider.as_str() { + "cloudflare" => update_cloudflare(&self.client, config, record_type, domains, ip).await, + "alidns" => update_alidns(&self.client, config, record_type, domains, ip).await, + "tencentcloud" | "dnspod" | "tencent" => { + update_tencentcloud(&self.client, config, record_type, domains, ip).await + } + other => Err(format!("unsupported DDNS provider: {other}")), + } + } +} + +impl Default for DdnsManager { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, Default)] +struct ProviderUpdateSummary { + old_ip: Option, + changed: bool, + message: String, +} + +fn normalize_domains(domains: &[String]) -> Vec { + domains + .iter() + .flat_map(|item| item.lines()) + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(ToString::to_string) + .collect() +} + +fn ddns_failure_key(provider: &str, record_type: &str, domains: &[String]) -> String { + format!("{}|{}|{}", provider, record_type, domains.join(",")) +} + +fn default_urls_for_record(record_type: &str) -> Vec { + if record_type == "AAAA" { + vec![ + "https://api6.ipify.org".to_string(), + "https://speed.neu6.edu.cn/getIP.php".to_string(), + "https://v6.ident.me".to_string(), + "https://myip6.ipip.net".to_string(), + "https://6.ipw.cn".to_string(), + ] + } else { + vec![ + "https://api.ipify.org".to_string(), + "https://ip.3322.net".to_string(), + "https://4.ident.me".to_string(), + "https://ddns.oray.com/checkip".to_string(), + "https://4.ipw.cn".to_string(), + ] + } +} + +fn extract_ip_from_text(text: &str, record_type: &str) -> Option { + for token in + text.split(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ',' | ';' | '<' | '>')) + { + let candidate = + token.trim_matches(|c: char| !c.is_ascii_hexdigit() && c != '.' && c != ':'); + if let Ok(ip) = candidate.parse::() { + match (record_type, ip) { + ("A", IpAddr::V4(_)) => return Some(candidate.to_string()), + ("AAAA", IpAddr::V6(_)) => return Some(candidate.to_string()), + _ => {} + } + } + } + None +} + +async fn get_ip_from_interface( + ip_config: &DdnsIpConfig, + record_type: &str, +) -> Result { + let interfaces = read_network_interfaces(None).await?; + let family = NetworkAddressFamily::from_ddns_record_type(record_type) + .ok_or_else(|| format!("unsupported DDNS record type: {record_type}"))?; + let iface = if ip_config.interface_name.trim().is_empty() { + preferred_interface_for_family(&interfaces, family) + } else { + interfaces.iter().find(|iface| { + iface.name == ip_config.interface_name + && !interface_addresses_for_family(&iface.ip_addresses, family).is_empty() + }) + } + .ok_or_else(|| "no matching network interface found".to_string())?; + + interface_addresses_for_family(&iface.ip_addresses, family) + .first() + .map(|addr| addr.address.clone()) + .ok_or_else(|| format!("no {record_type} address found on {}", iface.name)) +} + +async fn update_cloudflare( + client: &Client, + config: &DdnsConfig, + record_type: &str, + domains: &[String], + ip: &str, +) -> Result { + let zone_id = config.access_id.trim(); + let token = config.access_secret.trim(); + if zone_id.is_empty() || token.is_empty() { + return Err("Cloudflare Zone ID or API Token is not configured".to_string()); + } + + let mut changed = false; + let mut old_ip = None; + for domain in domains { + let list_url = format!( + "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records?type={record_type}&name={domain}" + ); + let listed = client + .get(&list_url) + .bearer_auth(token) + .send() + .await + .map_err(|err| format!("Cloudflare list request failed: {err}"))? + .json::() + .await + .map_err(|err| format!("Cloudflare list response parse failed: {err}"))?; + ensure_cloudflare_success(&listed)?; + let first = listed + .get("result") + .and_then(JsonValue::as_array) + .and_then(|items| items.first()) + .cloned(); + let current = first + .as_ref() + .and_then(|record| record.get("content")) + .and_then(JsonValue::as_str) + .map(ToString::to_string); + if old_ip.is_none() { + old_ip = current.clone(); + } + if current.as_deref() == Some(ip) { + continue; + } + + let payload = json!({ + "type": record_type, + "name": domain, + "content": ip, + "ttl": config.ttl, + "proxied": false, + }); + let response = if let Some(record) = first { + let record_id = record + .get("id") + .and_then(JsonValue::as_str) + .ok_or_else(|| "Cloudflare record id missing".to_string())?; + client + .put(format!( + "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}" + )) + .bearer_auth(token) + .json(&payload) + .send() + .await + } else { + client + .post(format!( + "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records" + )) + .bearer_auth(token) + .json(&payload) + .send() + .await + } + .map_err(|err| format!("Cloudflare update request failed: {err}"))? + .json::() + .await + .map_err(|err| format!("Cloudflare update response parse failed: {err}"))?; + ensure_cloudflare_success(&response)?; + changed = true; + } + + Ok(ProviderUpdateSummary { + old_ip, + changed, + message: if changed { + format!("Cloudflare {record_type} records updated to {ip}") + } else { + format!("Cloudflare {record_type} records unchanged") + }, + }) +} + +fn ensure_cloudflare_success(value: &JsonValue) -> Result<(), String> { + if value + .get("success") + .and_then(JsonValue::as_bool) + .unwrap_or(false) + { + return Ok(()); + } + Err(format!( + "Cloudflare returned error: {}", + value + .get("errors") + .cloned() + .unwrap_or_else(|| json!("unknown error")) + )) +} + +async fn update_alidns( + client: &Client, + config: &DdnsConfig, + record_type: &str, + domains: &[String], + ip: &str, +) -> Result { + if config.access_id.trim().is_empty() || config.access_secret.trim().is_empty() { + return Err("AliDNS AccessKey ID or Secret is not configured".to_string()); + } + + let mut changed = false; + let mut old_ip = None; + for fqdn in domains { + let parsed = split_domain(fqdn)?; + let mut describe = BTreeMap::new(); + describe.insert("DomainName".to_string(), parsed.domain.clone()); + describe.insert("RRKeyWord".to_string(), parsed.sub_domain.clone()); + describe.insert("Type".to_string(), record_type.to_string()); + let listed = alidns_request(client, config, "DescribeDomainRecords", describe).await?; + let records = listed + .get("DomainRecords") + .and_then(|v| v.get("Record")) + .and_then(JsonValue::as_array) + .cloned() + .unwrap_or_default(); + let current = records.iter().find(|record| { + record.get("RR").and_then(JsonValue::as_str) == Some(parsed.sub_domain.as_str()) + && record.get("Type").and_then(JsonValue::as_str) == Some(record_type) + }); + let current_ip = current + .and_then(|record| record.get("Value")) + .and_then(JsonValue::as_str) + .map(ToString::to_string); + if old_ip.is_none() { + old_ip = current_ip.clone(); + } + if current_ip.as_deref() == Some(ip) { + continue; + } + + let mut params = BTreeMap::new(); + params.insert("RR".to_string(), parsed.sub_domain.clone()); + params.insert("Type".to_string(), record_type.to_string()); + params.insert("Value".to_string(), ip.to_string()); + params.insert("TTL".to_string(), config.ttl.to_string()); + if let Some(record) = current { + let record_id = record + .get("RecordId") + .and_then(JsonValue::as_str) + .ok_or_else(|| "AliDNS RecordId missing".to_string())?; + params.insert("RecordId".to_string(), record_id.to_string()); + alidns_request(client, config, "UpdateDomainRecord", params).await?; + } else { + params.insert("DomainName".to_string(), parsed.domain.clone()); + alidns_request(client, config, "AddDomainRecord", params).await?; + } + changed = true; + } + + Ok(ProviderUpdateSummary { + old_ip, + changed, + message: if changed { + format!("AliDNS {record_type} records updated to {ip}") + } else { + format!("AliDNS {record_type} records unchanged") + }, + }) +} + +async fn alidns_request( + client: &Client, + config: &DdnsConfig, + action: &str, + mut params: BTreeMap, +) -> Result { + params.insert("Action".to_string(), action.to_string()); + params.insert("Version".to_string(), "2015-01-09".to_string()); + params.insert("Format".to_string(), "JSON".to_string()); + params.insert( + "AccessKeyId".to_string(), + config.access_id.trim().to_string(), + ); + params.insert("SignatureMethod".to_string(), "HMAC-SHA1".to_string()); + params.insert("SignatureVersion".to_string(), "1.0".to_string()); + params.insert("SignatureNonce".to_string(), nonce()); + params.insert( + "Timestamp".to_string(), + Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + ); + + let canonical = canonical_query(¶ms, ali_percent_encode); + let string_to_sign = format!("GET&%2F&{}", ali_percent_encode(&canonical)); + let key = format!("{}&", config.access_secret.trim()); + let signature = hmac_base64( + hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY, + key.as_bytes(), + string_to_sign.as_bytes(), + ); + let url = format!( + "https://alidns.aliyuncs.com/?{}&Signature={}", + canonical, + ali_percent_encode(&signature) + ); + let value = client + .get(url) + .send() + .await + .map_err(|err| format!("AliDNS request failed: {err}"))? + .json::() + .await + .map_err(|err| format!("AliDNS response parse failed: {err}"))?; + if let Some(message) = value.get("Message").and_then(JsonValue::as_str) { + return Err(format!("AliDNS {action} failed: {message}")); + } + Ok(value) +} + +async fn update_tencentcloud( + client: &Client, + config: &DdnsConfig, + record_type: &str, + domains: &[String], + ip: &str, +) -> Result { + if config.access_id.trim().is_empty() || config.access_secret.trim().is_empty() { + return Err("Tencent Cloud/DNSPod ID or Token is not configured".to_string()); + } + if !config.access_id.trim().starts_with("AKID") { + return update_dnspod_token(client, config, record_type, domains, ip).await; + } + + let mut changed = false; + let mut old_ip = None; + for fqdn in domains { + let parsed = split_domain(fqdn)?; + let listed = tencentcloud_request( + client, + config, + "DescribeRecordList", + json!({ + "Domain": parsed.domain, + "Subdomain": parsed.sub_domain, + "RecordType": record_type, + }), + ) + .await?; + let records = listed + .get("Response") + .and_then(|v| v.get("RecordList")) + .and_then(JsonValue::as_array) + .cloned() + .unwrap_or_default(); + let current = records.first(); + let current_ip = current + .and_then(|record| record.get("Value")) + .and_then(JsonValue::as_str) + .map(ToString::to_string); + if old_ip.is_none() { + old_ip = current_ip.clone(); + } + if current_ip.as_deref() == Some(ip) { + continue; + } + + if let Some(record) = current { + let record_id = record + .get("RecordId") + .or_else(|| record.get("Id")) + .and_then(JsonValue::as_u64) + .ok_or_else(|| "Tencent Cloud RecordId missing".to_string())?; + tencentcloud_request( + client, + config, + "ModifyRecord", + json!({ + "Domain": parsed.domain, + "SubDomain": parsed.sub_domain, + "RecordType": record_type, + "RecordLine": "默认", + "Value": ip, + "RecordId": record_id, + "TTL": config.ttl, + }), + ) + .await?; + } else { + tencentcloud_request( + client, + config, + "CreateRecord", + json!({ + "Domain": parsed.domain, + "SubDomain": parsed.sub_domain, + "RecordType": record_type, + "RecordLine": "默认", + "Value": ip, + "TTL": config.ttl, + }), + ) + .await?; + } + changed = true; + } + + Ok(ProviderUpdateSummary { + old_ip, + changed, + message: if changed { + format!("Tencent Cloud {record_type} records updated to {ip}") + } else { + format!("Tencent Cloud {record_type} records unchanged") + }, + }) +} + +async fn update_dnspod_token( + client: &Client, + config: &DdnsConfig, + record_type: &str, + domains: &[String], + ip: &str, +) -> Result { + let mut changed = false; + let mut old_ip = None; + for fqdn in domains { + let parsed = split_domain(fqdn)?; + let mut list_params = BTreeMap::new(); + list_params.insert("domain".to_string(), parsed.domain.clone()); + list_params.insert("sub_domain".to_string(), parsed.sub_domain.clone()); + list_params.insert("record_type".to_string(), record_type.to_string()); + let listed = dnspod_token_request(client, config, "Record.List", list_params).await?; + let records = listed + .get("records") + .and_then(JsonValue::as_array) + .cloned() + .unwrap_or_default(); + let current = records.iter().find(|record| { + record.get("type").and_then(JsonValue::as_str) == Some(record_type) + && record.get("name").and_then(JsonValue::as_str) + == Some(parsed.sub_domain.as_str()) + }); + let current_ip = current + .and_then(|record| record.get("value")) + .and_then(JsonValue::as_str) + .map(ToString::to_string); + if old_ip.is_none() { + old_ip = current_ip.clone(); + } + if current_ip.as_deref() == Some(ip) { + continue; + } + + let mut params = BTreeMap::new(); + params.insert("domain".to_string(), parsed.domain.clone()); + params.insert("sub_domain".to_string(), parsed.sub_domain.clone()); + params.insert("record_type".to_string(), record_type.to_string()); + params.insert("record_line".to_string(), "默认".to_string()); + params.insert("value".to_string(), ip.to_string()); + params.insert("ttl".to_string(), config.ttl.to_string()); + if let Some(record) = current { + let record_id = record + .get("id") + .and_then(JsonValue::as_str) + .ok_or_else(|| "DNSPod RecordId missing".to_string())?; + params.insert("record_id".to_string(), record_id.to_string()); + dnspod_token_request(client, config, "Record.Modify", params).await?; + } else { + dnspod_token_request(client, config, "Record.Create", params).await?; + } + changed = true; + } + + Ok(ProviderUpdateSummary { + old_ip, + changed, + message: if changed { + format!("Tencent Cloud {record_type} records updated to {ip}") + } else { + format!("Tencent Cloud {record_type} records unchanged") + }, + }) +} + +async fn dnspod_token_request( + client: &Client, + config: &DdnsConfig, + action: &str, + mut params: BTreeMap, +) -> Result { + params.insert( + "login_token".to_string(), + format!( + "{},{}", + config.access_id.trim(), + config.access_secret.trim() + ), + ); + params.insert("format".to_string(), "json".to_string()); + let value = client + .post(format!("https://dnsapi.cn/{action}")) + .form(¶ms) + .send() + .await + .map_err(|err| format!("DNSPod {action} request failed: {err}"))? + .json::() + .await + .map_err(|err| format!("DNSPod {action} response parse failed: {err}"))?; + let status = value.get("status").cloned().unwrap_or_else(|| json!({})); + let code = status + .get("code") + .and_then(JsonValue::as_str) + .unwrap_or_default(); + if code != "1" { + let message = status + .get("message") + .and_then(JsonValue::as_str) + .unwrap_or("unknown error"); + return Err(format!("DNSPod {action} failed: {message}")); + } + Ok(value) +} + +async fn tencentcloud_request( + client: &Client, + config: &DdnsConfig, + action: &str, + payload: JsonValue, +) -> Result { + const HOST: &str = "dnspod.tencentcloudapi.com"; + const SERVICE: &str = "dnspod"; + const VERSION: &str = "2021-03-23"; + let timestamp = current_timestamp_secs(); + let date = chrono::DateTime::::from_timestamp(timestamp as i64, 0) + .unwrap_or_else(Utc::now) + .format("%Y-%m-%d") + .to_string(); + let payload_text = payload.to_string(); + let hashed_payload = sha256_hex(payload_text.as_bytes()); + let canonical_request = format!( + "POST\n/\n\ncontent-type:application/json; charset=utf-8\nhost:{HOST}\n\ncontent-type;host\n{hashed_payload}" + ); + let credential_scope = format!("{date}/{SERVICE}/tc3_request"); + let string_to_sign = format!( + "TC3-HMAC-SHA256\n{timestamp}\n{credential_scope}\n{}", + sha256_hex(canonical_request.as_bytes()) + ); + let secret_date = hmac_sha256( + format!("TC3{}", config.access_secret.trim()).as_bytes(), + date.as_bytes(), + ); + let secret_service = hmac_sha256(&secret_date, SERVICE.as_bytes()); + let secret_signing = hmac_sha256(&secret_service, b"tc3_request"); + let signature = hex_lower(&hmac_sha256(&secret_signing, string_to_sign.as_bytes())); + let authorization = format!( + "TC3-HMAC-SHA256 Credential={}/{}, SignedHeaders=content-type;host, Signature={}", + config.access_id.trim(), + credential_scope, + signature + ); + + let value = client + .post(format!("https://{HOST}")) + .header("Authorization", authorization) + .header("Content-Type", "application/json; charset=utf-8") + .header("Host", HOST) + .header("X-TC-Action", action) + .header("X-TC-Timestamp", timestamp.to_string()) + .header("X-TC-Version", VERSION) + .json(&payload) + .send() + .await + .map_err(|err| format!("Tencent Cloud request failed: {err}"))? + .json::() + .await + .map_err(|err| format!("Tencent Cloud response parse failed: {err}"))?; + if let Some(error) = value.get("Response").and_then(|v| v.get("Error")) { + return Err(format!("Tencent Cloud {action} failed: {error}")); + } + Ok(value) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ParsedDomain { + domain: String, + sub_domain: String, +} + +fn split_domain(fqdn: &str) -> Result { + let trimmed = fqdn.trim().trim_end_matches('.'); + let labels: Vec<&str> = trimmed.split('.').filter(|s| !s.is_empty()).collect(); + if labels.len() < 2 { + return Err(format!("invalid domain: {fqdn}")); + } + let public_suffix_len = if labels.len() >= 3 + && matches!( + format!("{}.{}", labels[labels.len() - 2], labels[labels.len() - 1]).as_str(), + "com.cn" | "net.cn" | "org.cn" | "gov.cn" | "co.uk" | "org.uk" | "com.au" + ) { + 2 + } else { + 1 + }; + let root_start = labels.len().saturating_sub(public_suffix_len + 1); + let domain = labels[root_start..].join("."); + let sub_domain = if root_start == 0 { + "@".to_string() + } else { + labels[..root_start].join(".") + }; + Ok(ParsedDomain { domain, sub_domain }) +} + +fn canonical_query(params: &BTreeMap, encoder: fn(&str) -> String) -> String { + params + .iter() + .map(|(key, value)| format!("{}={}", encoder(key), encoder(value))) + .collect::>() + .join("&") +} + +fn ali_percent_encode(input: &str) -> String { + let mut out = String::new(); + for b in input.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +fn hmac_base64(algorithm: hmac::Algorithm, key: &[u8], data: &[u8]) -> String { + let key = hmac::Key::new(algorithm, key); + let tag = hmac::sign(&key, data); + general_purpose::STANDARD.encode(tag.as_ref()) +} + +fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec { + let key = hmac::Key::new(hmac::HMAC_SHA256, key); + hmac::sign(&key, data).as_ref().to_vec() +} + +fn sha256_hex(data: &[u8]) -> String { + hex_lower(digest::digest(&digest::SHA256, data).as_ref()) +} + +fn hex_lower(data: &[u8]) -> String { + data.iter().map(|b| format!("{b:02x}")).collect() +} + +fn current_timestamp_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} + +fn nonce() -> String { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos().to_string()) + .unwrap_or_else(|_| "0".to_string()) +} + +fn now_string() -> String { + Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true) +} + +fn beijing_time_string() -> String { + (Utc::now() + ChronoDuration::hours(8)) + .format("%Y-%m-%d %H:%M:%S") + .to_string() +} + +// WLAN client management. These handlers use NetworkManager's command-line +// frontend for compatibility with minimal Debian images where nmcli is the +// stable administrative interface already present with NetworkManager. + +pub async fn wlan_status() -> Result { + let radio = run_nmcli(&["-t", "-f", "WIFI,WIFI-HW", "general", "status"]).await?; + let fields = split_nmcli_fields(radio.lines().next().unwrap_or_default()); + let enabled = fields.first().map(|v| v == "enabled").unwrap_or(false); + let hardware_enabled = fields.get(1).map(|v| v == "enabled").unwrap_or(false); + let devices = run_nmcli(&[ + "-t", + "-f", + "DEVICE,TYPE,STATE,CONNECTION", + "device", + "status", + ]) + .await?; + let wifi = devices.lines().find_map(|line| { + let fields = split_nmcli_fields(line); + if fields.get(1).map(String::as_str) == Some("wifi") { + Some(fields) + } else { + None + } + }); + let Some(wifi) = wifi else { + return Ok(WlanStatusResponse { + available: false, + enabled, + hardware_enabled, + ..WlanStatusResponse::default() + }); + }; + let iface = wifi.first().cloned().unwrap_or_default(); + let connected = wifi.get(2).map(|v| v == "connected").unwrap_or(false); + let connection_id = wifi.get(3).filter(|v| !v.is_empty() && *v != "--").cloned(); + let details = run_nmcli(&[ + "-t", + "-f", + "GENERAL.CONNECTION,IP4.ADDRESS,IP4.GATEWAY,IP6.ADDRESS", + "device", + "show", + &iface, + ]) + .await + .unwrap_or_default(); + let mut ipv4_addresses = Vec::new(); + let mut ipv4_gateway = None; + let mut ipv6_addresses = Vec::new(); + for line in details.lines() { + if let Some(value) = line.strip_prefix("IP4.ADDRESS") { + if let Some((_, address)) = value.split_once(':') { + ipv4_addresses.push(unescape_nmcli(address)); + } + } else if let Some(value) = line.strip_prefix("IP4.GATEWAY:") { + let gateway = unescape_nmcli(value); + if !gateway.is_empty() { + ipv4_gateway = Some(gateway); + } + } else if let Some(value) = line.strip_prefix("IP6.ADDRESS") { + if let Some((_, address)) = value.split_once(':') { + ipv6_addresses.push(unescape_nmcli(address)); + } + } + } + + Ok(WlanStatusResponse { + available: true, + enabled, + hardware_enabled, + interface_name: Some(iface), + connected, + ssid: connection_id.clone(), + connection_id, + ipv4_addresses, + ipv4_gateway, + ipv6_addresses, + }) +} + +pub async fn wlan_set_enabled(req: WlanEnabledRequest) -> Result { + run_nmcli(&["radio", "wifi", if req.enabled { "on" } else { "off" }]).await?; + wlan_status().await +} + +pub async fn wlan_scan() -> Result { + let output = run_nmcli(&[ + "-t", + "-f", + "SSID,BSSID,SIGNAL,SECURITY,ACTIVE", + "device", + "wifi", + "list", + "--rescan", + "yes", + ]) + .await?; + let mut networks = Vec::new(); + for line in output.lines() { + let fields = split_nmcli_fields(line); + let ssid = fields.first().cloned().unwrap_or_default(); + if ssid.is_empty() { + continue; + } + let security = fields.get(3).cloned().unwrap_or_default(); + networks.push(WlanNetwork { + ssid, + bssid: fields.get(1).cloned().unwrap_or_default(), + signal: fields + .get(2) + .and_then(|v| v.parse::().ok()) + .unwrap_or_default(), + secure: !security.trim().is_empty() && security.trim() != "--", + security, + connected: fields.get(4).map(|v| v == "yes").unwrap_or(false), + }); + } + Ok(WlanScanResponse { networks }) +} + +pub async fn wlan_profiles() -> Result { + let output = run_nmcli(&[ + "-t", + "-f", + "NAME,UUID,TYPE,DEVICE,AUTOCONNECT", + "connection", + "show", + ]) + .await?; + let mut profiles = Vec::new(); + for line in output.lines() { + let fields = split_nmcli_fields(line); + let connection_type = fields.get(2).map(String::as_str).unwrap_or_default(); + if connection_type != "wifi" && connection_type != "802-11-wireless" { + continue; + } + let id = fields.first().cloned().unwrap_or_default(); + let uuid = fields.get(1).cloned().unwrap_or_default(); + if id.is_empty() || uuid.is_empty() { + continue; + } + let device = fields.get(3).cloned().unwrap_or_default(); + let ssid = wlan_profile_ssid(&uuid).await.unwrap_or_else(|| id.clone()); + profiles.push(WlanSavedNetwork { + id, + uuid, + ssid, + interface_name: if device.is_empty() || device == "--" { + None + } else { + Some(device) + }, + active: fields + .get(3) + .map(|v| !v.is_empty() && v != "--") + .unwrap_or(false), + auto_join: fields.get(4).map(|v| v == "yes").unwrap_or(false), + }); + } + profiles.sort_by(|a, b| { + b.active + .cmp(&a.active) + .then_with(|| b.auto_join.cmp(&a.auto_join)) + .then_with(|| a.ssid.cmp(&b.ssid)) + }); + Ok(WlanProfilesResponse { profiles }) +} + +pub async fn wlan_forget(req: WlanForgetRequest) -> Result { + if !req.uuid.trim().is_empty() { + run_nmcli(&["connection", "delete", "uuid", req.uuid.trim()]).await?; + } else if !req.connection_id.trim().is_empty() { + run_nmcli(&["connection", "delete", req.connection_id.trim()]).await?; + } else { + return Err("connection id or uuid is required".to_string()); + } + wlan_profiles().await +} + +pub async fn wlan_connect(req: WlanConnectRequest) -> Result { + let status = wlan_status().await?; + let iface = status + .interface_name + .ok_or_else(|| "No WLAN interface found".to_string())?; + let mut args = vec!["device", "wifi", "connect", req.ssid.as_str()]; + if !req.password.is_empty() { + args.push("password"); + args.push(req.password.as_str()); + } + args.push("ifname"); + args.push(iface.as_str()); + run_nmcli(&args).await?; + let connection = wlan_status() + .await? + .connection_id + .unwrap_or_else(|| req.ssid.clone()); + let auto = if req.auto_join { "yes" } else { "no" }; + let _ = run_nmcli(&[ + "connection", + "modify", + connection.as_str(), + "connection.autoconnect", + auto, + "ipv4.route-metric", + WLAN_ROUTE_METRIC, + "ipv6.route-metric", + WLAN_ROUTE_METRIC, + ]) + .await; + wlan_status().await +} + +pub async fn wlan_disconnect() -> Result { + let status = wlan_status().await?; + if let Some(iface) = status.interface_name { + run_nmcli(&["device", "disconnect", iface.as_str()]).await?; + } + wlan_status().await +} + +pub async fn wlan_save_profile(req: WlanProfileRequest) -> Result { + run_nmcli(&[ + "connection", + "modify", + req.connection_id.as_str(), + "ipv4.route-metric", + WLAN_ROUTE_METRIC, + "ipv6.route-metric", + WLAN_ROUTE_METRIC, + ]) + .await?; + if let Some(auto_join) = req.auto_join { + run_nmcli(&[ + "connection", + "modify", + req.connection_id.as_str(), + "connection.autoconnect", + if auto_join { "yes" } else { "no" }, + ]) + .await?; + } + match req.ipv4_mode.as_deref() { + Some("dhcp") | Some("auto") => { + run_nmcli(&[ + "connection", + "modify", + req.connection_id.as_str(), + "ipv4.method", + "auto", + "ipv4.addresses", + "", + "ipv4.gateway", + "", + ]) + .await?; + } + Some("manual") => { + let address = req + .ipv4_address + .as_deref() + .ok_or_else(|| "IPv4 address is required for manual mode".to_string())?; + let prefix = req.ipv4_prefix.unwrap_or(24); + let gateway = req + .ipv4_gateway + .as_deref() + .ok_or_else(|| "IPv4 gateway is required for manual mode".to_string())?; + let cidr = format!("{address}/{prefix}"); + run_nmcli(&[ + "connection", + "modify", + req.connection_id.as_str(), + "ipv4.method", + "manual", + "ipv4.addresses", + cidr.as_str(), + "ipv4.gateway", + gateway, + ]) + .await?; + } + _ => {} + } + wlan_status().await +} + +async fn wlan_profile_ssid(uuid: &str) -> Option { + let output = run_nmcli(&[ + "-t", + "-f", + "802-11-wireless.ssid", + "connection", + "show", + uuid, + ]) + .await + .ok()?; + output.lines().find_map(|line| { + line.strip_prefix("802-11-wireless.ssid:") + .map(unescape_nmcli) + .filter(|value| !value.is_empty()) + }) +} + +async fn run_nmcli(args: &[&str]) -> Result { + let output = tokio::process::Command::new("nmcli") + .env("LANG", "C.UTF-8") + .env("LC_ALL", "C.UTF-8") + .env("LC_MESSAGES", "C") + .args(args) + .output() + .await + .map_err(|err| format!("Failed to execute nmcli: {err}"))?; + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(if stderr.is_empty() { + format!("nmcli exited with {}", output.status) + } else { + stderr + }) + } +} + +fn split_nmcli_fields(line: &str) -> Vec { + let mut fields = Vec::new(); + let mut current = String::new(); + let mut escaped = false; + for ch in line.chars() { + if escaped { + current.push(ch); + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == ':' { + fields.push(current); + current = String::new(); + } else { + current.push(ch); + } + } + fields.push(current); + fields +} + +fn unescape_nmcli(value: &str) -> String { + split_nmcli_fields(value).join(":") +} + +#[cfg(test)] +mod tests { + use crate::models::IpAddress; + + use super::*; + + #[test] + fn splits_regular_domain() { + assert_eq!( + split_domain("home.example.com").unwrap(), + ParsedDomain { + domain: "example.com".to_string(), + sub_domain: "home".to_string(), + } + ); + assert_eq!( + split_domain("example.com").unwrap(), + ParsedDomain { + domain: "example.com".to_string(), + sub_domain: "@".to_string(), + } + ); + } + + #[test] + fn splits_common_two_part_public_suffix() { + assert_eq!( + split_domain("router.example.com.cn").unwrap(), + ParsedDomain { + domain: "example.com.cn".to_string(), + sub_domain: "router".to_string(), + } + ); + } + + #[test] + fn parses_ip_from_api_text() { + assert_eq!( + extract_ip_from_text("ip=203.0.113.10", "A").as_deref(), + Some("203.0.113.10") + ); + assert_eq!( + extract_ip_from_text("2001:db8::1\n", "AAAA").as_deref(), + Some("2001:db8::1") + ); + } + + #[test] + fn ddns_ipv6_interface_addresses_match_dashboard_public_scope() { + let addresses = vec![ + IpAddress { + address: "fe80::1".to_string(), + prefix_len: 64, + ip_type: "ipv6".to_string(), + scope: "link-local".to_string(), + }, + IpAddress { + address: "fd00::1".to_string(), + prefix_len: 64, + ip_type: "ipv6".to_string(), + scope: "private".to_string(), + }, + IpAddress { + address: "2408::64".to_string(), + prefix_len: 64, + ip_type: "ipv6".to_string(), + scope: "public".to_string(), + }, + IpAddress { + address: "2408::128".to_string(), + prefix_len: 128, + ip_type: "ipv6".to_string(), + scope: "public".to_string(), + }, + ]; + + let candidates = interface_addresses_for_family(&addresses, NetworkAddressFamily::Ipv6); + let candidate_addresses: Vec<&str> = candidates + .iter() + .map(|addr| addr.address.as_str()) + .collect(); + + assert_eq!(candidate_addresses, vec!["2408::128", "2408::64"]); + } + + fn test_interface( + name: &str, + is_wireless: bool, + is_cellular: bool, + is_default_ipv4: bool, + is_default_ipv6: bool, + ip_addresses: Vec, + ) -> crate::models::NetworkInterfaceInfo { + crate::models::NetworkInterfaceInfo { + name: name.to_string(), + status: "up".to_string(), + is_wireless, + is_cellular, + is_default_ipv4, + is_default_ipv6, + mac_address: None, + mtu: 1500, + ip_addresses, + rx_bytes: 0, + tx_bytes: 0, + rx_packets: 0, + tx_packets: 0, + rx_errors: 0, + tx_errors: 0, + } + } + + fn ipv4(address: &str) -> IpAddress { + IpAddress { + address: address.to_string(), + prefix_len: 24, + ip_type: "ipv4".to_string(), + scope: "private".to_string(), + } + } + + fn public_ipv6(address: &str) -> IpAddress { + IpAddress { + address: address.to_string(), + prefix_len: 64, + ip_type: "ipv6".to_string(), + scope: "public".to_string(), + } + } + + #[test] + fn ddns_interface_selection_prefers_wireless_default_route() { + let interfaces = vec![ + test_interface("wwan0", false, true, true, true, vec![ipv4("10.11.242.67")]), + test_interface( + "wlp1s0", + true, + false, + true, + false, + vec![ipv4("192.168.1.10")], + ), + ]; + + let selected = + preferred_interface_for_family(&interfaces, NetworkAddressFamily::Ipv4).unwrap(); + + assert_eq!(selected.name, "wlp1s0"); + } + + #[test] + fn ddns_interface_selection_uses_default_route_before_bridge_or_loopback() { + let interfaces = vec![ + test_interface("lo", false, false, false, false, vec![ipv4("127.0.0.1")]), + test_interface( + "nm-bridge", + false, + false, + false, + false, + vec![ipv4("192.168.68.1")], + ), + test_interface("wwan0", false, true, true, true, vec![ipv4("10.11.242.67")]), + ]; + + let selected = + preferred_interface_for_family(&interfaces, NetworkAddressFamily::Ipv4).unwrap(); + + assert_eq!(selected.name, "wwan0"); + } + + #[test] + fn ddns_interface_selection_uses_ipv6_default_route_per_record_family() { + let interfaces = vec![ + test_interface( + "nm-bridge", + false, + false, + false, + false, + vec![public_ipv6("2408::68")], + ), + test_interface( + "wwan0", + false, + true, + false, + true, + vec![public_ipv6("2408::cell")], + ), + ]; + + let selected = + preferred_interface_for_family(&interfaces, NetworkAddressFamily::Ipv6).unwrap(); + + assert_eq!(selected.name, "wwan0"); + } + + #[test] + fn splits_nmcli_escaped_fields() { + assert_eq!( + split_nmcli_fields(r"SSID:AA\:BB\:CC:90:WPA2:yes"), + vec!["SSID", "AA:BB:CC", "90", "WPA2", "yes"] + ); + } +} diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/backend/src/esim.rs b/packages/operation-registry/test/fixtures/upstream-58e2204/backend/src/esim.rs new file mode 100644 index 0000000..c5e284b --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/backend/src/esim.rs @@ -0,0 +1,1537 @@ +//! eUICC profile management for removable eSIM cards. +//! +//! In this project "eSIM mode" is a feature gate for managing profiles stored +//! on a physical eUICC SIM card inserted in the device. It does not switch +//! board-level SIM hardware and does not start background workers. + +use std::env; +use std::fs; +use std::io::{self, Cursor}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde_json::Value; +use tokio::sync::Mutex; + +use crate::config::ConfigManager; +use crate::models::{ + EsimCommandResponse, EsimDownloadRequest, EsimEuiccInfo, EsimLpacRepairRequest, + EsimLpacRepairResponse, EsimLpacStatusResponse, EsimProfile, EsimProfilesResponse, WorkMode, + WorkModeResponse, +}; + +const ESIM_SHORT_TIMEOUT_SECS: u64 = 20; +const ESIM_LONG_TIMEOUT_SECS: u64 = 60; +const LPAC_REPAIR_TIMEOUT_SECS: u64 = 120; +const LPAC_PROBE_TIMEOUT_SECS: u64 = 3; +const MAX_LPAC_DOWNLOAD_BYTES: usize = 25 * 1024 * 1024; +const LPAC_OFFICIAL_RELEASE_BASE_URL: &str = + "https://github.com/estkme-group/lpac/releases/latest/download"; +const LPAC_COMPAT_RELEASE_BASE_URL: &str = + "https://github.com/3899/SimAdmin/releases/download/lpac"; +const LPAC_COMPAT_MANIFEST_NAME: &str = "lpac.json"; +const PRIVATE_LPAC_DIR: &str = "/opt/simadmin/lpac"; +const PRIVATE_LPAC_PATH: &str = "/opt/simadmin/lpac/lpac"; + +#[derive(Debug, Clone)] +struct LpacAssetCandidate { + name: String, + url: String, +} + +#[derive(Debug)] +pub enum EsimApiError { + Disabled, + Unavailable(String), + Command(String), +} + +impl EsimApiError { + pub fn message(&self) -> String { + match self { + Self::Disabled => "eSIM module is disabled in current work mode".to_string(), + Self::Unavailable(message) | Self::Command(message) => message.clone(), + } + } +} + +pub struct EsimSupervisor { + config_manager: Arc, + lpac_lock: Mutex<()>, +} + +impl EsimSupervisor { + pub fn new(config_manager: Arc) -> Self { + Self { + config_manager, + lpac_lock: Mutex::new(()), + } + } + + pub async fn worker_running(&self) -> bool { + self.config_manager.get_work_mode() == WorkMode::Esim + } + + pub async fn switch_mode(&self, target: WorkMode) -> Result { + self.config_manager.set_work_mode(target)?; + let mode = self.config_manager.get_work_mode(); + Ok(WorkModeResponse { + mode, + // Kept for API compatibility with v1.0.5 clients. There is no + // worker after the simplification; true means eSIM APIs are enabled. + worker_running: mode == WorkMode::Esim, + }) + } + + pub async fn get_lpac_status(&self) -> Result { + if self.config_manager.get_work_mode() != WorkMode::Esim { + return Err(EsimApiError::Disabled); + } + + let _guard = self.lpac_lock.lock().await; + let raw_arch = detect_machine_arch() + .await + .unwrap_or_else(|err| format!("unknown ({err})")); + let arch = normalize_lpac_arch(&raw_arch).unwrap_or("").to_string(); + let glibc_version = detect_glibc_version().await.unwrap_or_default(); + let asset_name = if arch.is_empty() { + String::new() + } else { + recommended_lpac_asset_name(&arch, &glibc_version) + }; + let command_path = resolve_lpac_path(&self.config_manager.get_esim_config().lpac_path); + let probe = probe_lpac_binary(&command_path).await; + let message = if arch.is_empty() && !probe.usable { + format!("unsupported device architecture: {raw_arch}") + } else { + probe.message + }; + + Ok(EsimLpacStatusResponse { + installed: probe.installed, + usable: probe.usable, + path: command_path.to_string_lossy().to_string(), + arch, + glibc_version, + asset_name, + message, + source: read_lpac_source(), + }) + } + + pub async fn repair_lpac( + &self, + request: EsimLpacRepairRequest, + ) -> Result { + if self.config_manager.get_work_mode() != WorkMode::Esim { + return Err(EsimApiError::Disabled); + } + + let _guard = self.lpac_lock.lock().await; + let raw_arch = detect_machine_arch() + .await + .map_err(|err| EsimApiError::Command(format!("Failed to detect device arch: {err}")))?; + let arch = normalize_lpac_arch(&raw_arch).ok_or_else(|| { + EsimApiError::Command(format!("unsupported device architecture: {raw_arch}")) + })?; + let glibc_version = detect_glibc_version().await.unwrap_or_default(); + let requested_asset_url = request + .asset_url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let proxy_prefix = crate::ota::normalize_proxy_prefix(request.proxy_prefix); + let candidates = match requested_asset_url { + Some(asset_url) => vec![LpacAssetCandidate { + name: asset_url + .rsplit('/') + .next() + .filter(|value| !value.is_empty()) + .unwrap_or("custom-lpac.zip") + .to_string(), + url: asset_url, + }], + None => resolve_lpac_asset_candidates(arch, &glibc_version, &proxy_prefix).await, + }; + + let mut errors = Vec::new(); + for candidate in candidates { + let result = async { + let bytes = download_lpac_asset(&candidate.url, &proxy_prefix).await?; + install_lpac_asset(&bytes, &candidate.url).await?; + let probe = probe_lpac_binary(Path::new(PRIVATE_LPAC_PATH)).await; + if !probe.usable { + return Err(EsimApiError::Command(format!( + "lpac was installed but is not usable: {}", + probe.message + ))); + } + Ok::<(), EsimApiError>(()) + } + .await; + + match result { + Ok(()) => { + return Ok(EsimLpacRepairResponse { + installed: true, + path: PRIVATE_LPAC_PATH.to_string(), + arch: arch.to_string(), + asset_name: candidate.name, + asset_url: candidate.url, + message: "lpac installed and verified".to_string(), + }); + } + Err(err) => errors.push(format!("{}: {}", candidate.name, err.message())), + } + } + + Err(EsimApiError::Command(format!( + "No compatible lpac asset worked for arch={arch}, glibc={}. Tried: {}", + if glibc_version.is_empty() { + "unknown" + } else { + glibc_version.as_str() + }, + errors.join(" | ") + ))) + } + + async fn call_lpac( + &self, + action: &str, + args: &[&str], + timeout_seconds: u64, + ) -> Result { + if self.config_manager.get_work_mode() != WorkMode::Esim { + return Err(EsimApiError::Disabled); + } + + let _guard = self.lpac_lock.lock().await; + run_lpac_command( + &self.config_manager.get_esim_config().lpac_path, + action, + args, + timeout_seconds, + ) + .await + } + + pub async fn get_euicc_info(&self) -> Result { + let response = self + .call_lpac("info", &["chip", "info"], ESIM_SHORT_TIMEOUT_SECS) + .await?; + if !command_succeeded(&response) { + return Err(EsimApiError::Command(response.msg)); + } + let mut info = normalize_euicc_info(response); + if info.memory_total_kb.is_none() { + info.memory_total_customizable = Some(true); + let esim_config = self.config_manager.get_esim_config(); + if let Some(total_kb) = esim_config.custom_memory_total_kb { + info.memory_total_kb = Some(total_kb as f64); + } + } else { + info.memory_total_customizable = Some(false); + } + Ok(info) + } + + pub async fn get_profiles(&self) -> Result { + let response = self + .call_lpac("profiles", &["profile", "list"], ESIM_SHORT_TIMEOUT_SECS) + .await?; + if !command_succeeded(&response) { + return Err(EsimApiError::Command(response.msg)); + } + Ok(normalize_profiles(response)) + } + + pub async fn enable_profile(&self, iccid: String) -> Result { + self.call_lpac( + "enable", + &["profile", "enable", iccid.as_str(), "1"], + ESIM_LONG_TIMEOUT_SECS, + ) + .await + } + + pub async fn rename_profile( + &self, + iccid: String, + name: String, + ) -> Result { + self.call_lpac( + "rename", + &["profile", "nickname", iccid.as_str(), name.as_str()], + ESIM_LONG_TIMEOUT_SECS, + ) + .await + } + + pub async fn delete_profile(&self, iccid: String) -> Result { + self.call_lpac( + "delete", + &["profile", "delete", iccid.as_str()], + ESIM_LONG_TIMEOUT_SECS, + ) + .await + } + + pub async fn download_profile( + &self, + request: EsimDownloadRequest, + ) -> Result { + let mut args = vec![ + "profile", + "download", + "-s", + request.smdp.as_str(), + "-m", + request.matching_id.as_str(), + ]; + + let cc = request.confirmation_code.as_deref().unwrap_or("").trim(); + if !cc.is_empty() { + args.push("-c"); + args.push(cc); + } + + let imei = request.imei.as_deref().unwrap_or("").trim(); + if !imei.is_empty() { + args.push("-i"); + args.push(imei); + } + + // download can take up to 120 seconds + self.call_lpac("download", &args, 120).await + } +} + +fn command_succeeded(response: &EsimCommandResponse) -> bool { + response.code == 0 + && (response.status.is_empty() + || response.status.eq_ignore_ascii_case("success") + || response.status.eq_ignore_ascii_case("ok")) +} + +struct LpacProbe { + installed: bool, + usable: bool, + message: String, +} + +async fn detect_machine_arch() -> Result { + let output = tokio::time::timeout( + Duration::from_secs(LPAC_PROBE_TIMEOUT_SECS), + tokio::process::Command::new("uname").arg("-m").output(), + ) + .await + .map_err(|_| "uname -m timed out".to_string())? + .map_err(|err| format!("failed to run uname -m: {err}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(if stderr.is_empty() { + format!("uname -m exited with {}", output.status) + } else { + stderr + }); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +fn normalize_lpac_arch(raw: &str) -> Option<&'static str> { + match raw.trim() { + "aarch64" | "arm64" => Some("aarch64"), + "x86_64" | "amd64" => Some("x86_64"), + _ => None, + } +} + +async fn detect_glibc_version() -> Result { + if let Ok(output) = tokio::time::timeout( + Duration::from_secs(LPAC_PROBE_TIMEOUT_SECS), + tokio::process::Command::new("getconf") + .arg("GNU_LIBC_VERSION") + .output(), + ) + .await + { + if let Ok(output) = output { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + if let Some(version) = stdout.split_whitespace().last() { + if !version.trim().is_empty() { + return Ok(version.trim().to_string()); + } + } + } + } + } + + let output = tokio::time::timeout( + Duration::from_secs(LPAC_PROBE_TIMEOUT_SECS), + tokio::process::Command::new("ldd") + .arg("--version") + .output(), + ) + .await + .map_err(|_| "ldd --version timed out".to_string())? + .map_err(|err| format!("failed to run ldd --version: {err}"))?; + + let text = String::from_utf8_lossy(&output.stdout); + find_version_token(&text).ok_or_else(|| "failed to parse glibc version".to_string()) +} + +fn find_version_token(text: &str) -> Option { + text.split_whitespace().find_map(|token| { + let token = token.trim_matches(|ch: char| !ch.is_ascii_digit() && ch != '.'); + let has_dot = token.contains('.'); + let numeric = token.chars().all(|ch| ch.is_ascii_digit() || ch == '.'); + (has_dot && numeric).then(|| token.to_string()) + }) +} + +fn recommended_lpac_asset_name(arch: &str, glibc_version: &str) -> String { + if arch == "aarch64" && version_le("2.31", glibc_version).unwrap_or(false) { + return "lpac-linux-aarch64-glibc2.31.zip".to_string(); + } + format!("lpac-linux-{arch}.zip") +} + +async fn resolve_lpac_asset_candidates( + arch: &str, + glibc_version: &str, + proxy_prefix: &str, +) -> Vec { + let mut candidates = Vec::new(); + + if let Ok(mut manifest_candidates) = + fetch_compatible_lpac_candidates(arch, glibc_version, proxy_prefix).await + { + candidates.append(&mut manifest_candidates); + } + + for name in [ + format!("lpac-linux-{arch}.zip"), + format!("lpac-linux-{arch}-with-qmi.zip"), + format!("lpac-linux-{arch}-without-lto.zip"), + ] { + candidates.push(LpacAssetCandidate { + url: format!("{LPAC_OFFICIAL_RELEASE_BASE_URL}/{name}"), + name, + }); + } + + dedupe_lpac_candidates(candidates) +} + +async fn fetch_compatible_lpac_candidates( + arch: &str, + glibc_version: &str, + proxy_prefix: &str, +) -> Result, EsimApiError> { + let manifest_url = format!("{LPAC_COMPAT_RELEASE_BASE_URL}/{LPAC_COMPAT_MANIFEST_NAME}"); + let bytes = download_lpac_asset(&manifest_url, proxy_prefix).await?; + let manifest = serde_json::from_slice::(&bytes) + .map_err(|err| EsimApiError::Command(format!("Invalid lpac asset manifest: {err}")))?; + let Some(assets) = manifest.get("assets").and_then(Value::as_array) else { + return Ok(Vec::new()); + }; + + let mut items = assets + .iter() + .filter_map(|asset| { + let name = asset.get("name").and_then(Value::as_str)?; + let asset_arch = asset.get("arch").and_then(Value::as_str)?; + if asset_arch != arch { + return None; + } + let asset_glibc = asset.get("glibc").and_then(Value::as_str).unwrap_or(""); + if !asset_glibc.is_empty() + && !glibc_version.is_empty() + && !version_le(asset_glibc, glibc_version).unwrap_or(false) + { + return None; + } + Some((asset_glibc.to_string(), name.to_string())) + }) + .collect::>(); + + items.sort_by(|left, right| compare_version(&right.0, &left.0).then(left.1.cmp(&right.1))); + + Ok(items + .into_iter() + .map(|(_, name)| LpacAssetCandidate { + url: format!("{LPAC_COMPAT_RELEASE_BASE_URL}/{name}"), + name, + }) + .collect()) +} + +fn dedupe_lpac_candidates(candidates: Vec) -> Vec { + let mut seen = std::collections::HashSet::new(); + candidates + .into_iter() + .filter(|candidate| seen.insert(candidate.url.clone())) + .collect() +} + +fn version_le(left: &str, right: &str) -> Option { + Some(compare_version(left, right) != std::cmp::Ordering::Greater) +} + +fn compare_version(left: &str, right: &str) -> std::cmp::Ordering { + let left = parse_version_parts(left); + let right = parse_version_parts(right); + let len = left.len().max(right.len()); + for index in 0..len { + let a = *left.get(index).unwrap_or(&0); + let b = *right.get(index).unwrap_or(&0); + match a.cmp(&b) { + std::cmp::Ordering::Equal => continue, + ordering => return ordering, + } + } + std::cmp::Ordering::Equal +} + +fn parse_version_parts(value: &str) -> Vec { + value + .split('.') + .filter_map(|part| part.parse::().ok()) + .collect() +} + +async fn probe_lpac_binary(command_path: &Path) -> LpacProbe { + let mut command = tokio::process::Command::new(command_path); + configure_lpac_environment(&mut command, command_path); + + let output = match tokio::time::timeout( + Duration::from_secs(LPAC_PROBE_TIMEOUT_SECS), + command.output(), + ) + .await + { + Ok(Ok(output)) => output, + Ok(Err(err)) if err.kind() == std::io::ErrorKind::NotFound => { + return LpacProbe { + installed: false, + usable: false, + message: "lpac is not installed or not found in PATH".to_string(), + }; + } + Ok(Err(err)) => { + return LpacProbe { + installed: command_path.exists(), + usable: false, + message: format!("Failed to run lpac: {err}"), + }; + } + Err(_) => { + return LpacProbe { + installed: command_path.exists(), + usable: false, + message: "lpac probe timed out".to_string(), + }; + } + }; + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let combined = format!("{stdout}\n{stderr}"); + if combined.contains("GLIBC_") + || combined.contains("No such file or directory") + || combined.contains("Permission denied") + { + return LpacProbe { + installed: true, + usable: false, + message: if stderr.is_empty() { stdout } else { stderr }, + }; + } + + LpacProbe { + installed: true, + usable: true, + message: "lpac is available".to_string(), + } +} + +fn read_lpac_source() -> Option { + fs::read_to_string(Path::new(PRIVATE_LPAC_DIR).join("SOURCE.txt")) + .ok() + .map(|content| content.trim().to_string()) + .filter(|content| !content.is_empty()) +} + +async fn download_lpac_asset(asset_url: &str, proxy_prefix: &str) -> Result, EsimApiError> { + let client = reqwest::Client::builder() + .user_agent("SimAdmin lpac repair") + .timeout(Duration::from_secs(LPAC_REPAIR_TIMEOUT_SECS)) + .build() + .map_err(|err| EsimApiError::Command(format!("Failed to create HTTP client: {err}")))?; + + let mut urls = Vec::new(); + if !proxy_prefix.is_empty() { + urls.push(format!("{proxy_prefix}{asset_url}")); + } + urls.push(asset_url.to_string()); + + let mut last_error = String::new(); + for url in urls { + match client.get(&url).send().await { + Ok(response) => { + let status = response.status(); + if !status.is_success() { + last_error = format!("lpac download failed: HTTP {status}"); + continue; + } + if let Some(size) = response.content_length() { + if size > MAX_LPAC_DOWNLOAD_BYTES as u64 { + return Err(EsimApiError::Command(format!( + "lpac asset is too large: {size} bytes" + ))); + } + } + let bytes = response.bytes().await.map_err(|err| { + EsimApiError::Command(format!("Failed to read lpac asset: {err}")) + })?; + if bytes.len() > MAX_LPAC_DOWNLOAD_BYTES { + return Err(EsimApiError::Command(format!( + "lpac asset is too large: {} bytes", + bytes.len() + ))); + } + return Ok(bytes.to_vec()); + } + Err(err) => { + last_error = format!("Failed to download lpac asset: {err}"); + } + } + } + + Err(EsimApiError::Command(last_error)) +} + +async fn install_lpac_asset(bytes: &[u8], asset_url: &str) -> Result<(), EsimApiError> { + let stamp = current_millis(); + let temp_dir = env::temp_dir().join(format!("simadmin-lpac-repair-{stamp}")); + let extract_dir = temp_dir.join("extract"); + let install_parent = Path::new(PRIVATE_LPAC_DIR) + .parent() + .ok_or_else(|| EsimApiError::Command("Invalid lpac install path".to_string()))? + .to_path_buf(); + let new_dir = install_parent.join(format!(".lpac-new-{stamp}")); + let backup_dir = install_parent.join(format!(".lpac-old-{stamp}")); + + let result = async { + fs::create_dir_all(&extract_dir) + .map_err(|err| EsimApiError::Command(format!("Failed to create temp dir: {err}")))?; + extract_lpac_archive(bytes, &extract_dir).await?; + + let bundle_root = find_lpac_root(&extract_dir).ok_or_else(|| { + EsimApiError::Command("downloaded lpac asset does not contain lpac executable".to_string()) + })?; + + if new_dir.exists() { + fs::remove_dir_all(&new_dir).map_err(|err| { + EsimApiError::Command(format!("Failed to clean pending lpac dir: {err}")) + })?; + } + fs::create_dir_all(&new_dir) + .map_err(|err| EsimApiError::Command(format!("Failed to create lpac dir: {err}")))?; + copy_dir_recursive(&bundle_root, &new_dir)?; + copy_optional_lpac_libs(&extract_dir, &new_dir)?; + fs::write( + new_dir.join("SOURCE.txt"), + format!( + "lpac is installed from:\n{asset_url}\n\nProject:\nhttps://github.com/estkme-group/lpac\n" + ), + ) + .map_err(|err| EsimApiError::Command(format!("Failed to write lpac source: {err}")))?; + chmod_lpac_tree(&new_dir).await; + activate_lpac_tree(&new_dir, &backup_dir).await + } + .await; + + let _ = fs::remove_dir_all(&temp_dir); + if result.is_err() { + let _ = fs::remove_dir_all(&new_dir); + } + result +} + +async fn extract_lpac_archive(bytes: &[u8], target_dir: &Path) -> Result<(), EsimApiError> { + let bytes = bytes.to_vec(); + let target_dir = target_dir.to_path_buf(); + tokio::task::spawn_blocking(move || extract_zip_archive(bytes, &target_dir)) + .await + .map_err(|err| EsimApiError::Command(format!("lpac extraction task failed: {err}")))? +} + +fn extract_zip_archive(bytes: Vec, target_dir: &Path) -> Result<(), EsimApiError> { + let reader = Cursor::new(bytes); + let mut archive = zip::ZipArchive::new(reader) + .map_err(|err| EsimApiError::Command(format!("Invalid lpac zip archive: {err}")))?; + + for index in 0..archive.len() { + let mut file = archive.by_index(index).map_err(|err| { + EsimApiError::Command(format!("Failed to read lpac zip entry: {err}")) + })?; + let Some(path) = file.enclosed_name().map(|path| target_dir.join(path)) else { + continue; + }; + + if file.is_dir() { + fs::create_dir_all(&path).map_err(|err| { + EsimApiError::Command(format!("Failed to create extracted directory: {err}")) + })?; + continue; + } + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|err| { + EsimApiError::Command(format!("Failed to create extracted parent dir: {err}")) + })?; + } + let mut output = fs::File::create(&path).map_err(|err| { + EsimApiError::Command(format!("Failed to create extracted file: {err}")) + })?; + io::copy(&mut file, &mut output) + .map_err(|err| EsimApiError::Command(format!("Failed to extract lpac file: {err}")))?; + } + + Ok(()) +} + +fn find_lpac_root(root: &Path) -> Option { + let direct = root.join("lpac"); + if direct.is_file() { + return Some(root.to_path_buf()); + } + + let executables = root.join("executables").join("lpac"); + if executables.is_file() { + return executables.parent().map(Path::to_path_buf); + } + + find_file_named(root, "lpac").and_then(|path| path.parent().map(Path::to_path_buf)) +} + +fn find_file_named(root: &Path, name: &str) -> Option { + let entries = fs::read_dir(root).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + if path.file_name().and_then(|value| value.to_str()) == Some(name) && path.is_file() { + return Some(path); + } + if path.is_dir() { + if let Some(found) = find_file_named(&path, name) { + return Some(found); + } + } + } + None +} + +fn copy_optional_lpac_libs(extract_dir: &Path, target_dir: &Path) -> Result<(), EsimApiError> { + let target_lib = target_dir.join("lib"); + if target_lib.exists() { + return Ok(()); + } + + for name in ["lib", "libraries"] { + let source = extract_dir.join(name); + if source.is_dir() { + fs::create_dir_all(&target_lib).map_err(|err| { + EsimApiError::Command(format!("Failed to create lpac lib dir: {err}")) + })?; + copy_dir_recursive(&source, &target_lib)?; + return Ok(()); + } + } + Ok(()) +} + +fn copy_dir_recursive(source: &Path, target: &Path) -> Result<(), EsimApiError> { + fs::create_dir_all(target) + .map_err(|err| EsimApiError::Command(format!("Failed to create directory: {err}")))?; + for entry in fs::read_dir(source) + .map_err(|err| EsimApiError::Command(format!("Failed to read directory: {err}")))? + { + let entry = + entry.map_err(|err| EsimApiError::Command(format!("Failed to read entry: {err}")))?; + let source_path = entry.path(); + let target_path = target.join(entry.file_name()); + let file_type = entry + .file_type() + .map_err(|err| EsimApiError::Command(format!("Failed to read file type: {err}")))?; + if file_type.is_dir() { + copy_dir_recursive(&source_path, &target_path)?; + } else if file_type.is_file() { + fs::copy(&source_path, &target_path).map_err(|err| { + EsimApiError::Command(format!("Failed to copy {}: {err}", source_path.display())) + })?; + } + } + Ok(()) +} + +async fn chmod_lpac_tree(target_dir: &Path) { + let _ = tokio::process::Command::new("chmod") + .arg("-R") + .arg("a+rX") + .arg(target_dir) + .output() + .await; + let _ = tokio::process::Command::new("chmod") + .arg("0755") + .arg(target_dir.join("lpac")) + .output() + .await; +} + +async fn activate_lpac_tree(new_dir: &Path, backup_dir: &Path) -> Result<(), EsimApiError> { + let target_dir = Path::new(PRIVATE_LPAC_DIR); + if let Some(parent) = target_dir.parent() { + fs::create_dir_all(parent) + .map_err(|err| EsimApiError::Command(format!("Failed to create install dir: {err}")))?; + } + + if backup_dir.exists() { + fs::remove_dir_all(backup_dir) + .map_err(|err| EsimApiError::Command(format!("Failed to clean backup dir: {err}")))?; + } + + let had_existing = target_dir.exists(); + if had_existing { + fs::rename(target_dir, backup_dir).map_err(|err| { + EsimApiError::Command(format!("Failed to backup existing lpac: {err}")) + })?; + } + + let install_result = fs::rename(new_dir, target_dir) + .map_err(|err| EsimApiError::Command(format!("Failed to install lpac: {err}"))); + if let Err(err) = install_result { + if had_existing { + let _ = fs::rename(backup_dir, target_dir); + } + return Err(err); + } + + let probe = probe_lpac_binary(Path::new(PRIVATE_LPAC_PATH)).await; + if !probe.usable { + let _ = fs::remove_dir_all(target_dir); + if had_existing { + let _ = fs::rename(backup_dir, target_dir); + } + return Err(EsimApiError::Command(format!( + "Installed lpac is not usable: {}", + probe.message + ))); + } + + if had_existing { + let _ = fs::remove_dir_all(backup_dir); + } + Ok(()) +} + +fn current_millis() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() +} + +async fn run_lpac_command( + lpac_path: &str, + action: &str, + args: &[&str], + timeout_seconds: u64, +) -> Result { + let command_path = resolve_lpac_path(lpac_path); + let mut command = tokio::process::Command::new(&command_path); + command.args(args); + configure_lpac_environment(&mut command, &command_path); + + let output = tokio::time::timeout(Duration::from_secs(timeout_seconds), command.output()) + .await + .map_err(|_| { + EsimApiError::Command(format!("lpac {action} timed out after {timeout_seconds}s")) + })? + .map_err(|err| { + if err.kind() == std::io::ErrorKind::NotFound { + EsimApiError::Unavailable( + "lpac is unavailable; use the eSIM Manager repair action, run \ + install_latest.sh, or set esim.lpac_path" + .to_string(), + ) + } else { + EsimApiError::Command(format!("Failed to spawn lpac: {err}")) + } + })?; + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + + if stdout.is_empty() { + if output.status.success() { + return Ok(EsimCommandResponse { + code: 0, + status: "ok".to_string(), + action: action.to_string(), + msg: "ok".to_string(), + data: None, + }); + } + return Err(EsimApiError::Command(if stderr.is_empty() { + format!("lpac {action} exited with status {}", output.status) + } else { + stderr + })); + } + + // Since lpac stdout can contain multiple JSON objects separated by whitespace/newlines (e.g. progress updates followed by the final lpa object), + // we search from the end of the stdout to find the last valid JSON block that starts with {"type" or simply {. + let mut parsed_value = None; + let mut search_pos = stdout.len(); + while let Some(pos) = stdout[..search_pos].rfind(r#"{"type"#) { + if let Ok(val) = serde_json::from_str::(&stdout[pos..]) { + parsed_value = Some(val); + break; + } + if pos == 0 { + break; + } + search_pos = pos; + } + + if parsed_value.is_none() { + let mut search_pos = stdout.len(); + while let Some(pos) = stdout[..search_pos].rfind('{') { + if let Ok(val) = serde_json::from_str::(&stdout[pos..]) { + parsed_value = Some(val); + break; + } + if pos == 0 { + break; + } + search_pos = pos; + } + } + + let value = match parsed_value { + Some(val) => val, + None => serde_json::from_str::(&stdout).map_err(|err| { + EsimApiError::Command(format!( + "Invalid JSON from lpac {action}: {err}; stdout: {stdout}" + )) + })?, + }; + + Ok(normalize_lpac_response( + action, + value, + stderr, + output.status.success(), + )) +} + +fn resolve_lpac_path(lpac_path: &str) -> PathBuf { + let configured = lpac_path.trim(); + if configured.is_empty() || configured == "lpac" || configured == PRIVATE_LPAC_PATH { + let private_lpac = Path::new(PRIVATE_LPAC_PATH); + if private_lpac.exists() { + return private_lpac.to_path_buf(); + } + return PathBuf::from("lpac"); + } + + PathBuf::from(configured) +} + +fn configure_lpac_environment(command: &mut tokio::process::Command, command_path: &Path) { + set_env_default(command, "LPAC_APDU", "qmi"); + set_env_default(command, "LPAC_HTTP", "curl"); + set_env_default(command, "LPAC_APDU_QMI_DEVICE", "/dev/wwan0qmi0"); + set_env_default(command, "LPAC_APDU_QMI_UIM_SLOT", "1"); + set_env_default(command, "LPAC_APDU_AT_DEVICE", "/dev/wwan0at0"); + + if let Some(parent) = command_path + .parent() + .filter(|path| !path.as_os_str().is_empty()) + { + let lib_dir = parent.join("lib"); + if lib_dir.is_dir() { + let mut ld_library_path = lib_dir.to_string_lossy().to_string(); + if let Some(existing) = env::var_os("LD_LIBRARY_PATH") { + ld_library_path.push(':'); + ld_library_path.push_str(&existing.to_string_lossy()); + } + command.env("LD_LIBRARY_PATH", ld_library_path); + } + } +} + +fn set_env_default(command: &mut tokio::process::Command, key: &str, value: &str) { + if env::var_os(key).is_none() { + command.env(key, value); + } +} + +fn normalize_lpac_response( + action: &str, + value: Value, + stderr: String, + process_success: bool, +) -> EsimCommandResponse { + if value.get("code").is_some() && value.get("status").is_some() { + let mut response: EsimCommandResponse = + serde_json::from_value(value).unwrap_or_else(|_| EsimCommandResponse { + code: if process_success { 0 } else { 1 }, + status: if process_success { "ok" } else { "error" }.to_string(), + action: action.to_string(), + msg: stderr.clone(), + data: None, + }); + if response.action.is_empty() { + response.action = action.to_string(); + } + if response.msg.is_empty() && !stderr.is_empty() { + response.msg = stderr; + } + return response; + } + + let payload = value.get("payload").unwrap_or(&value); + let code = payload + .get("code") + .and_then(|item| item.as_i64()) + .unwrap_or(if process_success { 0 } else { 1 }) as i32; + let msg = string_from(payload, &["message", "msg", "error"]) + .or_else(|| (!stderr.is_empty()).then_some(stderr)) + .unwrap_or_else(|| { + if code == 0 { + "success".to_string() + } else { + "lpac command failed".to_string() + } + }); + let data = payload + .get("data") + .cloned() + .or_else(|| value.get("data").cloned()); + + EsimCommandResponse { + code, + status: if code == 0 { "ok" } else { "error" }.to_string(), + action: action.to_string(), + msg, + data, + } +} + +fn eum_from_eid(eid: &str) -> Option<&'static str> { + let normalized = eid + .chars() + .filter(|ch| !ch.is_whitespace()) + .collect::() + .to_ascii_uppercase(); + if normalized.len() < 8 { + return None; + } + + match &normalized[..8] { + "89033023" => Some("Thales"), + "89033024" | "89039011" => Some("Idemia"), + "89044011" | "89044020" => Some("Giesecke+Devrient"), + "89049032" | "89041030" => Some("STMicroelectronics"), + "89043051" => Some("NXP Semiconductors"), + "89034011" => Some("Valid"), + "8904C012" => Some("Workz"), + "89014022" | "89014052" => Some("Kigen"), + "89046031" => Some("Infineon Technologies"), + "89086030" => Some("EastcomPeace"), + "89086016" => Some("HED"), + "89086011" => Some("China Mobile IoT"), + "89086002" => Some("Tongxin Micro"), + "89086026" | "89086027" => Some("Tianyu"), + "89086014" => Some("Hengbao"), + "89086012" => Some("Datang Micro"), + "89086004" => Some("ZTE ICT"), + _ => None, + } +} + +fn normalize_euicc_info(response: EsimCommandResponse) -> EsimEuiccInfo { + let data = response.data.unwrap_or(Value::Null); + let root = data + .get("euicc") + .or_else(|| data.get("euiccInfo")) + .unwrap_or(&data); + let euicc_info2 = object_from(root, &["EUICCInfo2", "euiccInfo2", "euicc_info2"]); + let ext_resource = euicc_info2 + .and_then(|info| object_from(info, &["extCardResource", "ExtCardResource"])) + .or_else(|| object_from(root, &["extCardResource", "ExtCardResource"])); + let eid = string_from(root, &["eid", "EID", "eidValue", "eid_value"]) + .or_else(|| string_from(&data, &["eid", "EID", "eidValue", "eid_value"])) + .unwrap_or_default(); + let raw_manufacturer = string_from(root, &["manufacturer", "vendor", "maker"]).or_else(|| { + euicc_info2.and_then(|info| string_from(info, &["manufacturer", "vendor", "maker"])) + }); + + EsimEuiccInfo { + eid: eid.clone(), + status: string_from(root, &["status", "state"]).unwrap_or_else(|| "ready".to_string()), + manufacturer: eum_from_eid(&eid) + .map(ToString::to_string) + .or(raw_manufacturer) + .unwrap_or_default(), + memory_total_kb: number_from( + root, + &[ + "memory_total_kb", + "total_kb", + "total", + "totalNonVolatileMemoryKb", + ], + ) + .or_else(|| { + ext_resource.and_then(|resource| { + memory_kb_from_bytes( + resource, + &[ + "totalNonVolatileMemory", + "total_non_volatile_memory", + "nonVolatileMemory", + ], + ) + }) + }), + memory_available_kb: number_from( + root, + &[ + "memory_available_kb", + "available_kb", + "free_kb", + "available", + "freeNonVolatileMemoryKb", + ], + ) + .or_else(|| { + ext_resource.and_then(|resource| { + memory_kb_from_bytes( + resource, + &[ + "freeNonVolatileMemory", + "free_non_volatile_memory", + "availableNonVolatileMemory", + ], + ) + }) + }), + memory_total_customizable: None, + updated_at: None, + raw: data, + } +} + +fn normalize_profiles(response: EsimCommandResponse) -> EsimProfilesResponse { + let data = response.data.unwrap_or(Value::Null); + let null = Value::Null; + let profiles_value = if let Some(profiles) = data.get("profiles") { + profiles + } else if let Some(profile_info) = data.get("profileInfo") { + profile_info + } else if let Some(profile_info) = data.get("profile_info") { + profile_info + } else if data.is_array() { + &data + } else { + &null + }; + + let profiles = profiles_value + .as_array() + .map(|items| items.iter().map(normalize_profile).collect()) + .unwrap_or_default(); + + EsimProfilesResponse { profiles } +} + +pub fn normalize_profile(value: &Value) -> EsimProfile { + let null = Value::Null; + let ppr = value.get("ppr").unwrap_or(&null); + let operator = value + .get("originalOperator") + .or_else(|| value.get("original_operator")) + .or_else(|| value.get("operator")) + .unwrap_or(&null); + let imsi = string_from(value, &["imsi", "IMSI", "profileImsi", "profile_imsi"]); + let mut mcc = string_from(value, &["mcc", "MCC"]) + .or_else(|| string_from(operator, &["mcc", "MCC"])) + .or_else(|| mccmnc_part(value, 0)) + .or_else(|| mccmnc_part(operator, 0)); + let mut mnc = string_from(value, &["mnc", "MNC"]) + .or_else(|| string_from(operator, &["mnc", "MNC"])) + .or_else(|| mccmnc_part(value, 1)) + .or_else(|| mccmnc_part(operator, 1)); + if (mcc.is_none() || mnc.is_none()) && imsi.as_deref().is_some() { + if let Some((imsi_mcc, imsi_mnc)) = split_mcc_mnc_from_imsi(imsi.as_deref().unwrap_or("")) { + if mcc.is_none() { + mcc = Some(imsi_mcc); + } + if mnc.is_none() { + mnc = Some(imsi_mnc); + } + } + } + + EsimProfile { + iccid: { + let raw_iccid = string_from(value, &["iccid", "ICCID", "id"]).unwrap_or_default(); + raw_iccid.chars().filter(|c| c.is_ascii_digit()).collect() + }, + name: string_from( + value, + &[ + "profileNickname", + "profile_nickname", + "nickname", + "name", + "profileName", + "profile_name", + "serviceProviderName", + ], + ) + .unwrap_or_default(), + provider: string_from( + value, + &[ + "serviceProviderName", + "service_provider_name", + "provider", + "service_provider", + "spn", + "carrier", + "operatorName", + "profileOwner", + "profileOwer", + ], + ) + .or_else(|| string_from(operator, &["name", "operatorName", "displayName"])) + .or_else(|| string_value(operator)) + .or_else(|| string_from(value, &["profileName", "profile_name"])) + .unwrap_or_default(), + state: profile_state_from(value), + profile_class: profile_class_from(value).unwrap_or_default(), + imsi, + msisdn: string_from( + value, + &[ + "msisdn", + "MSISDN", + "phone_number", + "phoneNumber", + "phone", + "ownNumber", + "own_number", + "number", + ], + ), + smsc: string_from( + value, + &[ + "smsc", + "SMSC", + "sms_center", + "smsCenter", + "smscAddress", + "smsc_address", + ], + ), + smdp: string_from( + value, + &[ + "smdp", + "smdp_address", + "smdpAddress", + "smdp+", + "smdpServer", + "smdp_server", + "dpAddress", + "defaultDpAddress", + ], + ), + matching_id: None, + isdp_aid: string_from(value, &["isdpAid", "isdp_aid", "aid"]), + mcc, + mnc, + disable_allowed: bool_from(value, &["disable_allowed", "disableAllowed"]) + .or_else(|| bool_from(ppr, &["disableAllowed", "disable_allowed"])) + .or_else(|| policy_allows(value, &["disable", "disabling"])) + .or(Some(true)), + delete_allowed: bool_from(value, &["delete_allowed", "deleteAllowed"]) + .or_else(|| bool_from(ppr, &["deleteAllowed", "delete_allowed"])) + .or_else(|| policy_allows(value, &["delete", "deletion"])) + .or(Some(true)), + updated_at: None, + raw: value.clone(), + } +} + +fn object_from<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a Value> { + keys.iter() + .find_map(|key| value.get(*key).filter(|item| item.is_object())) +} + +fn string_value(value: &Value) -> Option { + match value { + Value::String(text) => { + let trimmed = text.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + } + Value::Number(number) => Some(number.to_string()), + Value::Bool(flag) => Some(flag.to_string()), + _ => None, + } +} + +fn string_from(value: &Value, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| value.get(*key).and_then(string_value)) +} + +fn number_from(value: &Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + value.get(*key).and_then(|item| match item { + Value::Number(number) => number.as_f64(), + Value::String(text) => text.trim().parse::().ok(), + _ => None, + }) + }) +} + +fn memory_kb_from_bytes(value: &Value, keys: &[&str]) -> Option { + number_from(value, keys).map(|bytes| bytes / 1000.0) +} + +fn bool_from(value: &Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + value.get(*key).and_then(|item| match item { + Value::Bool(flag) => Some(*flag), + Value::Number(number) => number.as_i64().map(|n| n != 0), + Value::String(text) if matches_bool(text, true) => Some(true), + Value::String(text) if matches_bool(text, false) => Some(false), + _ => None, + }) + }) +} + +fn matches_bool(value: &str, expected: bool) -> bool { + let normalized = value.trim().to_ascii_lowercase(); + let truthy = ["1", "true", "yes", "on", "enabled", "active", "allowed"]; + let falsy = ["0", "false", "no", "off", "disabled", "inactive", "denied"]; + if expected { + truthy.contains(&normalized.as_str()) + } else { + falsy.contains(&normalized.as_str()) + } +} + +fn profile_state_from(value: &Value) -> String { + for key in ["state", "status", "profileState", "profile_state"] { + if let Some(raw) = value.get(key) { + if let Some(state) = normalize_profile_state(raw) { + return state; + } + } + } + bool_from(value, &["enabled", "active", "is_enabled", "is_active"]) + .map(|enabled| { + if enabled { + "enabled".to_string() + } else { + "disabled".to_string() + } + }) + .unwrap_or_else(|| "unknown".to_string()) +} + +fn normalize_profile_state(value: &Value) -> Option { + match value { + Value::Number(number) => number.as_i64().map(|state| { + if state == 1 { + "enabled".to_string() + } else if state == 0 { + "disabled".to_string() + } else { + state.to_string() + } + }), + Value::Bool(enabled) => Some(if *enabled { + "enabled".to_string() + } else { + "disabled".to_string() + }), + Value::String(text) => { + let normalized = text.trim().to_ascii_lowercase(); + if normalized.is_empty() { + None + } else if matches!(normalized.as_str(), "1" | "enabled" | "active") { + Some("enabled".to_string()) + } else if matches!(normalized.as_str(), "0" | "disabled" | "inactive") { + Some("disabled".to_string()) + } else { + Some(normalized) + } + } + _ => None, + } +} + +fn profile_class_from(value: &Value) -> Option { + for key in ["class", "profile_class", "profileClass"] { + let Some(raw) = value.get(key) else { + continue; + }; + match raw { + Value::Number(number) => { + return number.as_i64().map(|class| match class { + 0 => "test".to_string(), + 1 => "provisioning".to_string(), + 2 => "operational".to_string(), + _ => class.to_string(), + }); + } + _ => { + if let Some(text) = string_value(raw) { + return Some(text); + } + } + } + } + None +} + +fn mccmnc_part(value: &Value, part: usize) -> Option { + let mccmnc = string_from( + value, + &[ + "mccmnc", + "mcc_mnc", + "mccMnc", + "plmn", + "operatorCode", + "operator_code", + "operatorIdentifier", + "operator_identifier", + ], + )?; + let mccmnc = mccmnc.trim(); + if part == 0 { + (mccmnc.len() >= 3).then(|| mccmnc[..3].to_string()) + } else { + (mccmnc.len() > 3).then(|| mccmnc[3..].to_string()) + } +} + +fn split_mcc_mnc_from_imsi(imsi: &str) -> Option<(String, String)> { + let digits = imsi.trim(); + if digits.len() < 5 || !digits.chars().all(|ch| ch.is_ascii_digit()) { + return None; + } + let mcc = digits[..3].to_string(); + let mnc_len = if mcc == "460" { + 2 + } else { + 3.min(digits.len() - 3) + }; + if digits.len() < 3 + mnc_len { + return None; + } + Some((mcc, digits[3..3 + mnc_len].to_string())) +} + +fn policy_allows(value: &Value, deny_markers: &[&str]) -> Option { + let rules = value + .get("profilePolicyRules") + .or_else(|| value.get("profile_policy_rules")) + .or_else(|| value.get("policyRules")) + .or_else(|| value.get("rules"))?; + let mut saw_rule = false; + if let Some(items) = rules.as_array() { + for item in items { + let text = string_value(item).unwrap_or_else(|| item.to_string()); + let text = text.to_ascii_lowercase(); + if deny_markers.iter().any(|marker| text.contains(marker)) { + return Some(false); + } + saw_rule = true; + } + } else { + let text = string_value(rules).unwrap_or_else(|| rules.to_string()); + let text = text.to_ascii_lowercase(); + if deny_markers.iter().any(|marker| text.contains(marker)) { + return Some(false); + } + saw_rule = !text.trim().is_empty(); + } + saw_rule.then_some(true) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parses_lpac_chip_info_aliases() { + let response = EsimCommandResponse { + data: Some(json!({ + "eidValue": "89086030EXAMPLE", + "EUICCInfo2": { + "extCardResource": { + "freeNonVolatileMemory": 405123, + "totalNonVolatileMemory": 478900 + } + } + })), + ..Default::default() + }; + + let info = normalize_euicc_info(response); + assert_eq!(info.eid, "89086030EXAMPLE"); + assert_eq!(info.manufacturer, "EastcomPeace"); + assert_eq!(info.memory_available_kb, Some(405.123)); + assert_eq!(info.memory_total_kb, Some(478.9)); + } + + #[test] + fn parses_lpac_profile_aliases() { + let profile = normalize_profile(&json!({ + "iccid": "89812000EXAMPLEICCID00", + "isdpAid": "TEST_ISDP_AID", + "profileState": 1, + "profileNickname": "主卡", + "serviceProviderName": "BillionConnect", + "profileName": "BillionConnect", + "profileClass": 2, + "imsi": "001010" + })); + + assert_eq!(profile.name, "主卡"); + assert_eq!(profile.provider, "BillionConnect"); + assert_eq!(profile.state, "enabled"); + assert_eq!(profile.profile_class, "operational"); + assert_eq!(profile.mcc.as_deref(), Some("001")); + assert_eq!(profile.mnc.as_deref(), Some("010")); + assert_eq!(profile.disable_allowed, Some(true)); + assert_eq!(profile.delete_allowed, Some(true)); + } +} diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/backend/src/handlers.rs b/packages/operation-registry/test/fixtures/upstream-58e2204/backend/src/handlers.rs new file mode 100644 index 0000000..d880412 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/backend/src/handlers.rs @@ -0,0 +1,4252 @@ +//! API 处理器模块 (ModemManager 版) +//! +//! 包含所有 HTTP API 的处理函数 + +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use serde::Deserialize; +use serde_json::json; +use std::collections::HashMap; +use std::fs; +use std::process::{Command, Output}; +use std::sync::atomic::Ordering; +use std::sync::{Arc, OnceLock}; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; +use tracing::{error, info, warn}; +use zbus::Connection; + +use crate::{ + config::ApnConfig, + esim::EsimApiError, + models::*, + modem_manager::{ + self, answer_call, apply_roaming_policy, current_sim_identity, + find_nm_modem_connection_pub, get_airplane_mode, get_band_lock_status, + get_baseband_restart_progress, get_call_by_path, get_call_settings, get_cell_location, + get_cells_data, get_data_connection_status, get_device_info_data, get_is_roaming_mm, + get_network_info_data, get_operators_list, get_radio_mode, get_signal_strength, + get_sim_info_data_with_cache, hangup_all_calls, hangup_call, list_apn_contexts, + list_current_calls, make_call, nm_set_autoconnect_pub, power_cycle_sim_for_profile_switch, + refresh_sim_details_background, register_operator_auto, register_operator_manual, + restart_baseband, scan_operators, send_sms, set_airplane_mode, set_apn_on_bearer, + set_band_lock, set_call_waiting, set_data_connection_with_apn, set_radio_mode, + sim_details_cache_missing, start_cell_monitoring, stop_cell_monitoring, + }, + state::AppState, + system_event::{ + codes as system_event_codes, mask_identifier, severity as system_event_severity, + status as system_event_status, + }, + utils::{ + connection_addresses_from_interfaces, format_uptime, get_active_interfaces, read_cpu_info, + read_cpu_load_sync, read_disk_info, read_interface_stats, read_memory_info, + read_network_interfaces, read_system_info, read_uptime, sample_cpu_usage, + }, +}; + +const ESIM_SIM_IDENTITY_TIMEOUT_SECS: u64 = 3; +const ESIM_CACHED_SIM_IDENTITY_TIMEOUT_MS: u64 = 800; +const SMS_DB_MAINTENANCE_DELETE_THRESHOLD: usize = 100; +const SMS_DB_MAINTENANCE_DELAY_SECS: u64 = 60; + +// ============ 基础接口 ============ + +/// 处理 OPTIONS 请求(CORS 预检) +pub async fn options_handler() -> impl IntoResponse { + StatusCode::NO_CONTENT +} + +/// GET /api/health +pub async fn health_check() -> impl IntoResponse { + ( + StatusCode::OK, + Json(json!({ + "status": "ok", + "message": "Service is running", + "platform": "linux-modem", + "version": env!("CARGO_PKG_VERSION"), + })), + ) +} + +fn esim_error_response(error: EsimApiError) -> (StatusCode, Json>) { + let status = match error { + EsimApiError::Disabled => StatusCode::FORBIDDEN, + EsimApiError::Unavailable(_) => StatusCode::SERVICE_UNAVAILABLE, + EsimApiError::Command(_) => StatusCode::OK, + }; + (status, Json(ApiResponse::::error(error.message()))) +} + +fn esim_command_succeeded(response: &EsimCommandResponse) -> bool { + response.code == 0 + && (response.status.is_empty() + || response.status.eq_ignore_ascii_case("success") + || response.status.eq_ignore_ascii_case("ok")) +} + +fn esim_profile_is_active(profile: &EsimProfile) -> bool { + matches!( + profile.state.trim().to_ascii_lowercase().as_str(), + "enabled" | "active" | "1" | "true" + ) +} + +fn esim_profile_state_is_unknown(state: &str) -> bool { + let state = state.trim(); + state.is_empty() || state.eq_ignore_ascii_case("unknown") +} + +fn sort_esim_profiles_for_display(profiles: &mut [EsimProfile]) { + profiles.sort_by(|left, right| { + let left_active = esim_profile_is_active(left); + let right_active = esim_profile_is_active(right); + right_active + .cmp(&left_active) + .then_with(|| { + left.name + .to_ascii_lowercase() + .cmp(&right.name.to_ascii_lowercase()) + }) + .then_with(|| left.iccid.cmp(&right.iccid)) + }); +} + +fn split_profile_operator_code(code: &str) -> (String, String) { + let digits: String = code.chars().filter(|ch| ch.is_ascii_digit()).collect(); + if digits.len() >= 6 { + (digits[..3].to_string(), digits[3..6].to_string()) + } else if digits.len() >= 5 { + (digits[..3].to_string(), digits[3..].to_string()) + } else { + (String::new(), String::new()) + } +} + +fn enrich_profiles_with_current_identity( + profiles: &mut [EsimProfile], + identity: &crate::modem_manager::SimIdentity, +) { + let current_index = profiles + .iter() + .position(|profile| !identity.iccid.is_empty() && profile.iccid == identity.iccid) + .or_else(|| profiles.iter().position(esim_profile_is_active)); + + let Some(profile) = current_index.and_then(|index| profiles.get_mut(index)) else { + return; + }; + + if esim_profile_state_is_unknown(&profile.state) + || !identity.iccid.is_empty() && profile.iccid == identity.iccid + { + profile.state = "enabled".to_string(); + } + if profile.imsi.is_none() && !identity.imsi.is_empty() { + profile.imsi = Some(identity.imsi.clone()); + } + let (mcc, mnc) = split_profile_operator_code(&identity.operator_id); + if profile.mcc.is_none() && !mcc.is_empty() { + profile.mcc = Some(mcc); + } + if profile.mnc.is_none() && !mnc.is_empty() { + profile.mnc = Some(mnc); + } + + if !identity.iccid.is_empty() { + for item in profiles { + if item.iccid != identity.iccid && esim_profile_state_is_unknown(&item.state) { + item.state = "disabled".to_string(); + } + } + } +} + +fn profile_cache_value(value: &str) -> Option { + let value = value.trim(); + (!value.is_empty()).then(|| value.to_string()) +} + +fn optional_profile_cache_value(value: &Option) -> Option { + value.as_deref().and_then(profile_cache_value) +} + +fn profile_cache_entry(profile: &EsimProfile) -> EsimProfileCacheEntry { + EsimProfileCacheEntry { + iccid: profile.iccid.trim().to_string(), + name: profile_cache_value(&profile.name), + provider: profile_cache_value(&profile.provider), + state: profile_cache_value(&profile.state), + profile_class: profile_cache_value(&profile.profile_class), + imsi: optional_profile_cache_value(&profile.imsi), + msisdn: optional_profile_cache_value(&profile.msisdn), + smsc: optional_profile_cache_value(&profile.smsc), + smdp: optional_profile_cache_value(&profile.smdp), + matching_id: optional_profile_cache_value(&profile.matching_id), + isdp_aid: optional_profile_cache_value(&profile.isdp_aid), + mcc: optional_profile_cache_value(&profile.mcc), + mnc: optional_profile_cache_value(&profile.mnc), + disable_allowed: profile.disable_allowed, + delete_allowed: profile.delete_allowed, + updated_at: String::new(), + } +} + +fn fill_cached_string(target: &mut String, cached: Option) { + if target.trim().is_empty() { + if let Some(value) = cached.and_then(|item| profile_cache_value(&item)) { + *target = value; + } + } +} + +fn fill_cached_option(target: &mut Option, cached: Option) { + if target.as_deref().unwrap_or("").trim().is_empty() { + if let Some(value) = cached.and_then(|item| profile_cache_value(&item)) { + *target = Some(value); + } + } +} + +fn hydrate_profile_from_cache(db: &Database, profile: &mut EsimProfile) { + let cache = match db.get_esim_profile_cache(&profile.iccid) { + Ok(Some(cache)) => cache, + Ok(None) => return, + Err(err) => { + warn!(iccid = %profile.iccid, error = %err, "Failed to read eSIM profile cache"); + return; + } + }; + + fill_cached_string(&mut profile.name, cache.name); + fill_cached_string(&mut profile.provider, cache.provider); + fill_cached_string(&mut profile.state, cache.state); + fill_cached_string(&mut profile.profile_class, cache.profile_class); + fill_cached_option(&mut profile.imsi, cache.imsi); + fill_cached_option(&mut profile.msisdn, cache.msisdn); + fill_cached_option(&mut profile.smsc, cache.smsc); + fill_cached_option(&mut profile.smdp, cache.smdp); + fill_cached_option(&mut profile.matching_id, cache.matching_id); + fill_cached_option(&mut profile.isdp_aid, cache.isdp_aid); + fill_cached_option(&mut profile.mcc, cache.mcc); + fill_cached_option(&mut profile.mnc, cache.mnc); +} + +fn hydrate_profiles_from_cache(db: &Database, profiles: &mut [EsimProfile]) { + for profile in profiles { + hydrate_profile_from_cache(db, profile); + } +} + +fn cache_esim_profiles(db: &Database, profiles: &[EsimProfile]) { + for profile in profiles { + if let Err(err) = db.upsert_esim_profile_cache(&profile_cache_entry(profile)) { + warn!(iccid = %profile.iccid, error = %err, "Failed to write eSIM profile cache"); + } + } +} + +fn profile_from_cache_entry(entry: EsimProfileCacheEntry) -> EsimProfile { + EsimProfile { + iccid: entry.iccid, + name: entry.name.unwrap_or_default(), + provider: entry.provider.unwrap_or_default(), + state: entry.state.unwrap_or_else(|| "unknown".to_string()), + profile_class: entry.profile_class.unwrap_or_default(), + imsi: entry.imsi, + msisdn: entry.msisdn, + smsc: entry.smsc, + smdp: entry.smdp, + matching_id: entry.matching_id, + isdp_aid: entry.isdp_aid, + mcc: entry.mcc, + mnc: entry.mnc, + disable_allowed: entry.disable_allowed.or(Some(true)), + delete_allowed: entry.delete_allowed.or(Some(true)), + updated_at: Some(entry.updated_at.clone()), + raw: json!({ + "source": "cache", + "updated_at": entry.updated_at, + }), + } +} + +fn cached_profiles_requested(query: &std::collections::HashMap) -> bool { + query + .get("cached") + .map(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" + ) + }) + .unwrap_or(false) +} + +// ============ 工作模式 / eSIM ============ + +fn live_refresh_requested(query: &std::collections::HashMap) -> bool { + query + .get("live") + .map(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" + ) + }) + .unwrap_or(false) +} + +fn euicc_cache_key(info: &EsimEuiccInfo) -> String { + let eid = info.eid.trim(); + if eid.is_empty() { + "default".to_string() + } else { + format!("eid:{eid}") + } +} + +fn euicc_cache_entry(info: &EsimEuiccInfo) -> EsimEuiccCacheEntry { + EsimEuiccCacheEntry { + cache_key: euicc_cache_key(info), + eid: info.eid.clone(), + status: info.status.clone(), + manufacturer: info.manufacturer.clone(), + memory_total_kb: info.memory_total_kb, + memory_available_kb: info.memory_available_kb, + memory_total_customizable: info.memory_total_customizable, + raw: info.raw.to_string(), + updated_at: info.updated_at.clone().unwrap_or_default(), + } +} + +fn euicc_from_cache_entry(entry: EsimEuiccCacheEntry) -> EsimEuiccInfo { + let mut raw: serde_json::Value = serde_json::from_str(&entry.raw).unwrap_or_else(|_| json!({})); + if let Some(object) = raw.as_object_mut() { + object.insert("source".to_string(), json!("cache")); + object.insert("updated_at".to_string(), json!(entry.updated_at.clone())); + } else { + raw = json!({ + "source": "cache", + "updated_at": entry.updated_at, + }); + } + + EsimEuiccInfo { + eid: entry.eid, + status: entry.status, + manufacturer: entry.manufacturer, + memory_total_kb: entry.memory_total_kb, + memory_available_kb: entry.memory_available_kb, + memory_total_customizable: entry.memory_total_customizable, + updated_at: Some(entry.updated_at), + raw, + } +} + +/// GET /api/work-mode +pub async fn get_work_mode_handler(State(app): State) -> impl IntoResponse { + let mode = app.config_manager.get_work_mode(); + let worker_running = app.esim_supervisor.worker_running().await; + ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Success", + WorkModeResponse { + mode, + worker_running, + }, + )), + ) +} + +/// POST /api/work-mode +pub async fn set_work_mode_handler( + State(app): State, + Json(payload): Json, +) -> impl IntoResponse { + if !payload.confirm { + return ( + StatusCode::BAD_REQUEST, + Json(ApiResponse::::error( + "Changing work mode requires confirm=true", + )), + ); + } + + let previous_mode = app.config_manager.get_work_mode(); + match app.esim_supervisor.switch_mode(payload.mode).await { + Ok(data) => { + if previous_mode != data.mode { + app.system_event_emitter + .emit_code( + system_event_codes::ESIM_WORK_MODE_CHANGED, + system_event_severity::INFO, + system_event_status::CHANGED, + "work_mode", + format!("工作模式从 {} 切换为 {}", previous_mode, data.mode), + ) + .await; + } + ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Work mode updated", data)), + ) + } + Err(err) => ( + StatusCode::OK, + Json(ApiResponse::::error(err)), + ), + } +} + +/// GET /api/esim/lpac/status +pub async fn get_esim_lpac_status_handler(State(app): State) -> impl IntoResponse { + match app.esim_supervisor.get_lpac_status().await { + Ok(data) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Success", data)), + ), + Err(err) => esim_error_response::(err), + } +} + +/// POST /api/esim/lpac/repair +pub async fn repair_esim_lpac_handler( + State(app): State, + Json(payload): Json, +) -> impl IntoResponse { + match app.esim_supervisor.repair_lpac(payload).await { + Ok(data) => { + app.system_event_emitter + .emit_code( + system_event_codes::ESIM_LPAC_REPAIR_SUCCEEDED, + system_event_severity::INFO, + system_event_status::SUCCEEDED, + "lpac", + "lpac 修复成功", + ) + .await; + ( + StatusCode::OK, + Json(ApiResponse::success_with_message("lpac repaired", data)), + ) + } + Err(err) => { + let message = err.message(); + app.system_event_emitter + .emit_code( + system_event_codes::ESIM_LPAC_REPAIR_FAILED, + system_event_severity::WARNING, + system_event_status::FAILED, + "lpac", + format!("lpac 修复失败: {message}"), + ) + .await; + esim_error_response::(err) + } + } +} + +/// GET /api/esim/config +pub async fn get_esim_config_handler(State(app): State) -> impl IntoResponse { + let esim_config = app.config_manager.get_esim_config(); + ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Success", esim_config)), + ) +} + +/// POST /api/esim/config +pub async fn set_esim_config_handler( + State(app): State, + Json(payload): Json, +) -> impl IntoResponse { + match app.config_manager.set_esim_config(payload) { + Ok(_) => ( + StatusCode::OK, + Json(ApiResponse::<()>::success_with_message( + "eSIM config updated successfully", + (), + )), + ), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiResponse::<()>::error(err)), + ), + } +} + +/// GET /api/esim/euicc +pub async fn get_esim_euicc_handler( + State(app): State, + Query(query): Query>, +) -> impl IntoResponse { + if !live_refresh_requested(&query) { + match app.database.latest_esim_euicc_cache() { + Ok(Some(entry)) => { + return ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Cached eUICC", + euicc_from_cache_entry(entry), + )), + ); + } + Ok(None) => {} + Err(err) => warn!(error = %err, "Failed to read eUICC cache"), + } + } + + match app.esim_supervisor.get_euicc_info().await { + Ok(mut data) => { + data.updated_at = Some(chrono::Utc::now().to_rfc3339()); + if let Err(err) = app + .database + .upsert_esim_euicc_cache(&euicc_cache_entry(&data)) + { + warn!(error = %err, "Failed to write eUICC cache"); + } + ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Success", data)), + ) + } + Err(err) => esim_error_response::(err), + } +} + +/// GET /api/esim/profiles +pub async fn get_esim_profiles_handler( + State(app): State, + Query(query): Query>, +) -> impl IntoResponse { + if cached_profiles_requested(&query) { + return match app.database.list_esim_profile_cache() { + Ok(entries) => { + let mut profiles: Vec = + entries.into_iter().map(profile_from_cache_entry).collect(); + let needs_identity = profiles + .iter() + .any(|profile| esim_profile_state_is_unknown(&profile.state)); + if needs_identity { + match tokio::time::timeout( + std::time::Duration::from_millis(ESIM_CACHED_SIM_IDENTITY_TIMEOUT_MS), + current_sim_identity(&app.dbus_conn), + ) + .await + { + Ok(Some(identity)) => { + enrich_profiles_with_current_identity(&mut profiles, &identity) + } + Ok(None) => {} + Err(_) => warn!( + timeout_ms = ESIM_CACHED_SIM_IDENTITY_TIMEOUT_MS, + "Timed out enriching cached eSIM profiles with current SIM identity" + ), + } + } + sort_esim_profiles_for_display(&mut profiles); + ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Cached profiles", + EsimProfilesResponse { profiles }, + )), + ) + } + Err(err) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed to read cached profiles: {err}" + ))), + ), + }; + } + + match app.esim_supervisor.get_profiles().await { + Ok(mut data) => { + hydrate_profiles_from_cache(&app.database, &mut data.profiles); + match tokio::time::timeout( + std::time::Duration::from_secs(ESIM_SIM_IDENTITY_TIMEOUT_SECS), + current_sim_identity(&app.dbus_conn), + ) + .await + { + Ok(Some(identity)) => { + enrich_profiles_with_current_identity(&mut data.profiles, &identity) + } + Ok(None) => {} + Err(_) => warn!( + timeout_secs = ESIM_SIM_IDENTITY_TIMEOUT_SECS, + "Timed out enriching eSIM profiles with current SIM identity" + ), + } + cache_esim_profiles(&app.database, &data.profiles); + sort_esim_profiles_for_display(&mut data.profiles); + ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Success", data)), + ) + } + Err(err) => esim_error_response::(err), + } +} + +/// POST /api/esim/profiles/{iccid}/enable +pub async fn enable_esim_profile_handler( + State(app): State, + Path(iccid): Path, +) -> impl IntoResponse { + let event_entity = mask_identifier(&iccid); + + modem_manager::reset_baseband_restart_progress(); + modem_manager::record_restart_step("启用 eSIM Profile", "running", None); + + let bg_app = app.clone(); + let bg_iccid = iccid.clone(); + let bg_event_entity = event_entity.clone(); + + tokio::spawn(async move { + let _guard = modem_manager::BasebandRestartRunGuard; + + match bg_app.esim_supervisor.enable_profile(bg_iccid).await { + Ok(data) => { + if esim_command_succeeded(&data) { + modem_manager::record_restart_step("启用 eSIM Profile", "ok", None); + let auto_connect_data = !bg_app.data_user_disabled.load(Ordering::SeqCst); + let allow_roaming = bg_app.config_manager.get_roaming_allowed(); + let apn_config = bg_app.config_manager.get_apn_config(); + match power_cycle_sim_for_profile_switch( + &bg_app.dbus_conn, + auto_connect_data, + allow_roaming, + Some(apn_config), + ) + .await + { + Ok(_recovery) => { + if bg_app.sms_resync.request_scan("profile-switch") { + info!("Requested SMS resync after eSIM profile switch"); + } else { + warn!("Failed to request SMS resync after eSIM profile switch"); + } + bg_app + .system_event_emitter + .emit_code( + system_event_codes::ESIM_PROFILE_ENABLE_SUCCEEDED, + system_event_severity::INFO, + system_event_status::SUCCEEDED, + bg_event_entity, + "Profile 启用成功,基带恢复完成", + ) + .await; + } + Err(err) => { + bg_app + .system_event_emitter + .emit_code( + system_event_codes::ESIM_PROFILE_SWITCH_BASEBAND_RECOVERY_FAILED, + system_event_severity::CRITICAL, + system_event_status::FAILED, + bg_event_entity, + format!("Profile 切换后基带恢复失败: {err}"), + ) + .await; + if bg_app + .sms_resync + .request_scan("profile-switch-recovery-failed") + { + info!("Requested SMS resync after failed eSIM profile recovery"); + } else { + warn!( + "Failed to request SMS resync after failed eSIM profile recovery" + ); + } + } + } + } else { + modem_manager::record_restart_step( + "启用 eSIM Profile", + "error", + Some(data.msg.clone()), + ); + bg_app + .system_event_emitter + .emit_code( + system_event_codes::ESIM_PROFILE_ENABLE_FAILED, + system_event_severity::WARNING, + system_event_status::FAILED, + bg_event_entity.clone(), + format!("Profile 启用失败: {}", data.msg), + ) + .await; + } + } + Err(err) => { + let message = err.message(); + modem_manager::record_restart_step( + "启用 eSIM Profile", + "error", + Some(message.clone()), + ); + bg_app + .system_event_emitter + .emit_code( + system_event_codes::ESIM_PROFILE_ENABLE_FAILED, + system_event_severity::WARNING, + system_event_status::FAILED, + bg_event_entity.clone(), + format!("Profile 启用失败: {message}"), + ) + .await; + } + } + }); + + let response = EsimCommandResponse { + code: 0, + status: "success".to_string(), + action: "enable".to_string(), + msg: "Profile enable task started in background".to_string(), + data: None, + }; + + ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Profile enable requested", + response, + )), + ) +} + +/// POST /api/esim/profiles/{iccid}/rename +pub async fn rename_esim_profile_handler( + State(app): State, + Path(iccid): Path, + Json(payload): Json, +) -> impl IntoResponse { + let name = payload.name.trim().to_string(); + if name.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(ApiResponse::::error( + "Profile name cannot be empty", + )), + ); + } + match app.esim_supervisor.rename_profile(iccid, name).await { + Ok(data) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Profile renamed", data)), + ), + Err(err) => esim_error_response::(err), + } +} + +/// DELETE /api/esim/profiles/{iccid} +pub async fn delete_esim_profile_handler( + State(app): State, + Path(iccid): Path, +) -> impl IntoResponse { + match app.esim_supervisor.delete_profile(iccid.clone()).await { + Ok(data) => { + if esim_command_succeeded(&data) { + if let Err(err) = app.database.delete_esim_profile_cache(&iccid) { + warn!(iccid = %iccid, error = %err, "Failed to delete eSIM profile cache"); + } + app.system_event_emitter + .emit_code( + system_event_codes::ESIM_PROFILE_DELETED, + system_event_severity::WARNING, + system_event_status::SUCCEEDED, + mask_identifier(&iccid), + "Profile 已删除", + ) + .await; + } + ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Profile deleted", data)), + ) + } + Err(err) => esim_error_response::(err), + } +} + +fn find_and_normalize_profile(value: &serde_json::Value) -> Option { + if let Some(obj) = value.as_object() { + if obj.contains_key("iccid") || obj.contains_key("ICCID") { + return Some(crate::esim::normalize_profile(value)); + } + for (_, val) in obj { + if let Some(p) = find_and_normalize_profile(val) { + return Some(p); + } + } + } else if let Some(arr) = value.as_array() { + for val in arr { + if let Some(p) = find_and_normalize_profile(val) { + return Some(p); + } + } + } + None +} + +/// POST /api/esim/profiles +pub async fn download_esim_profile_handler( + State(app): State, + Json(payload): Json, +) -> impl IntoResponse { + let smdp = payload.smdp.trim().to_string(); + let matching_id = payload.matching_id.trim().to_string(); + if smdp.is_empty() || matching_id.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(ApiResponse::::error( + "SM-DP+ server and Matching ID cannot be empty", + )), + ); + } + + // 在写卡前,先异步读取一次卡上的所有 profile ICCID 集合,用于后续新卡判定 + let initial_iccids_opt: Option> = + app.esim_supervisor.get_profiles().await.ok().map(|resp| { + resp.profiles + .into_iter() + .map(|p| crate::utils::normalize_iccid(&p.iccid)) + .collect() + }); + + match app.esim_supervisor.download_profile(payload.clone()).await { + Ok(data) => { + if esim_command_succeeded(&data) { + // Attempt to recursively find the downloaded profile details in lpac's response + let profile_val = data.data.clone().unwrap_or(serde_json::Value::Null); + if let Some(mut profile) = find_and_normalize_profile(&profile_val) { + // Supplement SM-DP+ if not returned + if profile.smdp.as_deref().unwrap_or("").trim().is_empty() { + profile.smdp = Some(smdp.clone()); + } + if profile + .matching_id + .as_deref() + .unwrap_or("") + .trim() + .is_empty() + { + profile.matching_id = Some(matching_id.clone()); + } + + let entry = EsimProfileCacheEntry { + iccid: profile.iccid.clone(), + name: Some(profile.name.clone()), + provider: Some(profile.provider.clone()), + state: Some(profile.state.clone()), + profile_class: Some(profile.profile_class.clone()), + imsi: profile.imsi.clone(), + msisdn: profile.msisdn.clone(), + smsc: profile.smsc.clone(), + smdp: profile.smdp.clone(), + matching_id: profile.matching_id.clone(), + isdp_aid: profile.isdp_aid.clone(), + mcc: profile.mcc.clone(), + mnc: profile.mnc.clone(), + disable_allowed: profile.disable_allowed, + delete_allowed: profile.delete_allowed, + updated_at: chrono::Utc::now().to_rfc3339(), + }; + + if let Err(err) = app.database.upsert_esim_profile_cache(&entry) { + warn!(iccid = %entry.iccid, error = %err, "Failed to cache downloaded eSIM profile to database"); + } + + app.system_event_emitter + .emit_code( + system_event_codes::ESIM_PROFILE_DOWNLOAD_SUCCEEDED, + system_event_severity::INFO, + system_event_status::SUCCEEDED, + mask_identifier(&entry.iccid), + "Profile 写入并缓存成功", + ) + .await; + } else { + // Fallback if we couldn't parse the profile details from lpac. + // Query the profiles on the card to identify the new one(s) that lack smdp/matching_id in cache. + let mut cached_fallback_iccid = None; + + // 1. 等待 1.5 秒,让 eUICC 卡片状态恢复稳定 + tokio::time::sleep(std::time::Duration::from_millis(1500)).await; + + // 2. 尝试读取最新列表,最多重试 4 次,每次间隔 1.5 秒 + let mut profiles_resp = None; + for attempt in 1..=4 { + match app.esim_supervisor.get_profiles().await { + Ok(resp) => { + profiles_resp = Some(resp); + break; + } + Err(err) => { + warn!(attempt = attempt, error = ?err, "Failed to get profiles during fallback retry"); + if attempt < 4 { + tokio::time::sleep(std::time::Duration::from_millis(1500)) + .await; + } + } + } + } + + if let Some(resp) = profiles_resp { + if let Some(ref init_iccids) = initial_iccids_opt { + for p in resp.profiles { + let norm_iccid = crate::utils::normalize_iccid(&p.iccid); + let is_new_profile = !init_iccids.contains(&norm_iccid); + + if is_new_profile { + let needs_cache = + match app.database.get_esim_profile_cache(&p.iccid) { + Ok(Some(cached_entry)) => cached_entry + .smdp + .as_deref() + .unwrap_or("") + .trim() + .is_empty(), + _ => true, + }; + if needs_cache { + let entry = EsimProfileCacheEntry { + iccid: p.iccid.clone(), + name: Some(p.name.clone()), + provider: Some(p.provider.clone()), + state: Some(p.state.clone()), + profile_class: Some(p.profile_class.clone()), + imsi: p.imsi.clone(), + msisdn: p.msisdn.clone(), + smsc: p.smsc.clone(), + smdp: Some(smdp.clone()), + matching_id: Some(matching_id.clone()), + isdp_aid: p.isdp_aid.clone(), + mcc: p.mcc.clone(), + mnc: p.mnc.clone(), + disable_allowed: p.disable_allowed, + delete_allowed: p.delete_allowed, + updated_at: chrono::Utc::now().to_rfc3339(), + }; + if let Err(err) = + app.database.upsert_esim_profile_cache(&entry) + { + warn!(iccid = %entry.iccid, error = %err, "Failed to cache fallback eSIM profile to database"); + } else { + cached_fallback_iccid = Some(p.iccid.clone()); + } + } + } + } + } else { + warn!("Initial ICCIDs list was unavailable before writing; fallback difference detection skipped to prevent profile mismatch"); + } + } else { + error!("Failed to fetch profiles list after writing even with retries; fallback profile caching cannot proceed"); + } + + let event_entity = cached_fallback_iccid + .as_ref() + .map(|iccid| mask_identifier(iccid)) + .unwrap_or_else(|| "esim".to_string()); + + app.system_event_emitter + .emit_code( + system_event_codes::ESIM_PROFILE_DOWNLOAD_SUCCEEDED, + system_event_severity::INFO, + system_event_status::SUCCEEDED, + event_entity, + "Profile 写入成功,已通过列表扫描更新缓存", + ) + .await; + } + } else { + let msg = data.msg.clone(); + let is_refused = msg.contains("MatchingID is refused") + || msg.contains("es9p_initiate_authentication") + || msg.contains("es10b_load_bound_profile_package") + || data + .data + .as_ref() + .map(|v| { + let s = v.to_string(); + s.contains("MatchingID is refused") + || s.contains("es9p_initiate_authentication") + || s.contains("es10b_load_bound_profile_package") + }) + .unwrap_or(false); + + if is_refused { + info!("MatchingID is refused, attempting to bind matching info to the profile if it exists"); + let mut cached_fallback_iccid = None; + if let Ok(profiles_resp) = app.esim_supervisor.get_profiles().await { + for p in profiles_resp.profiles { + let needs_cache = match app.database.get_esim_profile_cache(&p.iccid) { + Ok(Some(cached_entry)) => { + cached_entry.smdp.as_deref().unwrap_or("").trim().is_empty() + } + _ => true, + }; + if needs_cache { + let entry = EsimProfileCacheEntry { + iccid: p.iccid.clone(), + name: Some(p.name.clone()), + provider: Some(p.provider.clone()), + state: Some(p.state.clone()), + profile_class: Some(p.profile_class.clone()), + imsi: p.imsi.clone(), + msisdn: p.msisdn.clone(), + smsc: p.smsc.clone(), + smdp: Some(smdp.clone()), + matching_id: Some(matching_id.clone()), + isdp_aid: p.isdp_aid.clone(), + mcc: p.mcc.clone(), + mnc: p.mnc.clone(), + disable_allowed: p.disable_allowed, + delete_allowed: p.delete_allowed, + updated_at: chrono::Utc::now().to_rfc3339(), + }; + if let Ok(_) = app.database.upsert_esim_profile_cache(&entry) { + cached_fallback_iccid = Some(p.iccid.clone()); + break; + } + } + } + } + if let Some(ref iccid) = cached_fallback_iccid { + app.system_event_emitter + .emit_code( + system_event_codes::ESIM_PROFILE_DOWNLOAD_SUCCEEDED, + system_event_severity::INFO, + system_event_status::SUCCEEDED, + mask_identifier(iccid), + "Profile 已被使用,成功将 Matching ID 绑定至对应卡片", + ) + .await; + } + } + + app.system_event_emitter + .emit_code( + system_event_codes::ESIM_PROFILE_DOWNLOAD_FAILED, + system_event_severity::WARNING, + system_event_status::FAILED, + "esim", + format!("Profile 写入失败: {}", data.msg), + ) + .await; + } + ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Profile downloaded", + data, + )), + ) + } + Err(err) => { + let message = err.message(); + app.system_event_emitter + .emit_code( + system_event_codes::ESIM_PROFILE_DOWNLOAD_FAILED, + system_event_severity::WARNING, + system_event_status::FAILED, + "esim", + format!("Profile 写入失败: {message}"), + ) + .await; + esim_error_response::(err) + } + } +} + +// ============ 设备信息 ============ + +/// GET /api/device +pub async fn get_device_info(State(conn): State>) -> impl IntoResponse { + match get_device_info_data(&conn).await { + Ok(data) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Success", data)), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ), + } +} + +// ============ SIM 卡 ============ + +fn sim_identity_from_response(data: &SimInfoResponse) -> modem_manager::SimIdentity { + let mut operator_id = data.registered_operator_code.trim().to_string(); + if operator_id.is_empty() && !data.mcc.is_empty() && !data.mnc.is_empty() { + operator_id = format!("{}{}", data.mcc, data.mnc); + } + modem_manager::SimIdentity { + iccid: data.iccid.clone(), + imsi: data.imsi.clone(), + operator_id, + } +} + +fn maybe_refresh_sim_details_after_fast_response( + conn: &Arc, + db: &Arc, + data: &SimInfoResponse, +) { + if !data.present { + return; + } + let identity = sim_identity_from_response(data); + if !sim_details_cache_missing(db, &identity) { + return; + } + let conn_bg = Arc::clone(conn); + let db_bg = Arc::clone(db); + tokio::spawn(async move { + refresh_sim_details_background(&conn_bg, &db_bg, false).await; + }); +} + +/// GET /api/sim +pub async fn get_sim_info( + State((conn, db)): State<(Arc, Arc)>, +) -> impl IntoResponse { + match get_sim_info_data_with_cache(&conn, Some(&db)).await { + Ok(data) => { + maybe_refresh_sim_details_after_fast_response(&conn, &db, &data); + ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Success", data)), + ) + } + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ), + } +} + +/// POST /api/sim/details/refresh +pub async fn refresh_sim_details_handler( + State((conn, db)): State<(Arc, Arc)>, +) -> impl IntoResponse { + let conn_bg = Arc::clone(&conn); + let db_bg = Arc::clone(&db); + tokio::spawn(async move { + refresh_sim_details_background(&conn_bg, &db_bg, true).await; + }); + + ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "SIM details refresh started", + json!({}), + )), + ) +} + +/// POST /api/sim/cache +pub async fn update_sim_cache_handler( + State(app): State, + Json(payload): Json, +) -> impl IntoResponse { + let identity = match tokio::time::timeout( + std::time::Duration::from_secs(ESIM_SIM_IDENTITY_TIMEOUT_SECS), + current_sim_identity(&app.dbus_conn), + ) + .await + { + Ok(Some(identity)) => identity, + _ => { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiResponse::::error( + "Unable to get current SIM identity", + )), + ); + } + }; + + if let Some(sms_center) = &payload.sms_center { + crate::modem_manager::cache_smsc_for_identity( + &app.database, + &identity, + sms_center, + "manual", + ); + } + + if let Some(phone_number) = &payload.phone_number { + crate::modem_manager::cache_own_numbers_for_identity( + &app.database, + &identity, + &[phone_number.clone()], + "manual", + ); + } + + ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "SIM cache updated", + json!({}), + )), + ) +} + +// ============ 网络信息 ============ + +/// GET /api/network +pub async fn get_network_info(State(conn): State>) -> impl IntoResponse { + match get_network_info_data(&conn).await { + Ok(data) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Success", data)), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ), + } +} + +/// GET /api/cells +pub async fn get_cells(State(conn): State>) -> impl IntoResponse { + match get_cells_data(&conn).await { + Ok(data) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Success", data)), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ), + } +} + +/// POST /api/cell-monitor/start +pub async fn start_cell_monitor_handler(State(app): State) -> impl IntoResponse { + if app.cell_monitoring_active.swap(true, Ordering::SeqCst) { + return ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Cell monitor already active", + json!({}), + )), + ); + } + + match start_cell_monitoring().await { + Ok(()) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Cell monitor activated", + json!({}), + )), + ), + Err(e) => { + app.cell_monitoring_active.store(false, Ordering::SeqCst); + ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ) + } + } +} + +/// POST /api/cell-monitor/stop +pub async fn stop_cell_monitor_handler(State(app): State) -> impl IntoResponse { + if !app.cell_monitoring_active.swap(false, Ordering::SeqCst) { + return ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Cell monitor already inactive", + json!({}), + )), + ); + } + + match stop_cell_monitoring().await { + Ok(()) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Cell monitor deactivated", + json!({}), + )), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ), + } +} + +/// GET /api/radio-mode +pub async fn get_radio_mode_handler(State(conn): State>) -> impl IntoResponse { + match get_radio_mode(&conn).await { + Ok(data) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Success", data)), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ), + } +} + +/// POST /api/radio-mode +pub async fn set_radio_mode_handler( + State(conn): State>, + Json(payload): Json, +) -> impl IntoResponse { + match set_radio_mode(&conn, payload.mode).await { + Ok(()) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Radio mode updated", + json!({}), + )), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ), + } +} + +/// GET /api/band-lock +pub async fn get_band_lock_handler(State(conn): State>) -> impl IntoResponse { + match get_band_lock_status(&conn).await { + Ok(data) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Success", data)), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ), + } +} + +/// POST /api/band-lock +pub async fn set_band_lock_handler( + State(conn): State>, + Json(payload): Json, +) -> impl IntoResponse { + match set_band_lock(&conn, &payload).await { + Ok(()) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Band selection updated", + json!({}), + )), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ), + } +} + +/// GET /api/location/cell-info +pub async fn get_cell_location_handler(State(conn): State>) -> impl IntoResponse { + match get_cell_location(&conn).await { + Ok(data) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Success", data)), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ), + } +} + +/// GET /api/network/operators +pub async fn get_network_operators(State(conn): State>) -> impl IntoResponse { + match get_operators_list(&conn).await { + Ok(data) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Success", data)), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ), + } +} + +/// GET /api/network/operators/scan +pub async fn scan_network_operators(State(conn): State>) -> impl IntoResponse { + match scan_operators(&conn).await { + Ok(data) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Success", data)), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ), + } +} + +/// POST /api/network/register-manual +pub async fn register_network_manual( + State(conn): State>, + Json(payload): Json, +) -> impl IntoResponse { + match register_operator_manual(&conn, &payload.mccmnc).await { + Ok(()) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Registration started", + json!({}), + )), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ), + } +} + +/// POST /api/network/register-auto +pub async fn register_network_auto(State(conn): State>) -> impl IntoResponse { + match register_operator_auto(&conn).await { + Ok(()) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Auto registration started", + json!({}), + )), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ), + } +} + +/// GET /api/apn +pub async fn get_apn_list_handler(State(app): State) -> impl IntoResponse { + let apn_config = app.config_manager.get_apn_config(); + match list_apn_contexts(&app.dbus_conn, Some(&apn_config)).await { + Ok(data) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Success", data)), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ), + } +} + +/// POST /api/apn +pub async fn set_apn_handler( + State(app): State, + Json(payload): Json, +) -> impl IntoResponse { + let mut apn_config = app.config_manager.get_apn_config(); + if let Some(apn) = &payload.apn { + apn_config.apn = apn.trim().to_string(); + } + if let Some(protocol) = &payload.protocol { + apn_config.protocol = protocol.trim().to_string(); + } + if let Some(username) = &payload.username { + apn_config.username = username.trim().to_string(); + } + if let Some(password) = &payload.password { + apn_config.password = password.clone(); + } + if let Some(auth_method) = &payload.auth_method { + apn_config.auth_method = auth_method.trim().to_string(); + } + if apn_config.protocol.trim().is_empty() { + apn_config.protocol = ApnConfig::default().protocol; + } + if apn_config.auth_method.trim().is_empty() { + apn_config.auth_method = ApnConfig::default().auth_method; + } + + if let Err(err) = app.config_manager.set_apn_config(apn_config) { + return ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed to save APN config: {}", + err + ))), + ); + } + + let context_path = payload.context_path.trim(); + if context_path.is_empty() || context_path.ends_with("/bearer/default") { + return ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "APN config saved", + json!({}), + )), + ); + } + + match set_apn_on_bearer(&app.dbus_conn, &payload).await { + Ok(()) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message("APN updated", json!({}))), + ), + Err(e) => { + warn!(error = %e, "APN config saved but bearer update failed"); + ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "APN config saved", + json!({ "bearer_update_error": e.to_string() }), + )), + ) + } + } +} + +/// GET /api/cell-lock +pub async fn get_cell_lock_status_handler(State(app): State) -> impl IntoResponse { + let store = app.cell_lock.lock().await; + let data = store.status(); + drop(store); + ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Success", data)), + ) +} + +/// POST /api/cell-lock +pub async fn set_cell_lock_handler( + State(app): State, + Json(payload): Json, +) -> impl IntoResponse { + let mut store = app.cell_lock.lock().await; + match store.apply(&payload) { + Ok(()) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "OK", + CellLockResult { success: true }, + )), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(e)), + ), + } +} + +/// POST /api/cell-lock/unlock-all +pub async fn unlock_all_cells_handler(State(app): State) -> impl IntoResponse { + let mut store = app.cell_lock.lock().await; + store.unlock_all(); + ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Unlocked", + CellLockResult { success: true }, + )), + ) +} + +/// GET /api/network/interfaces +pub async fn get_network_interfaces_info( + State(dbus_conn): State>, +) -> impl IntoResponse { + match read_network_interfaces(Some(&dbus_conn)).await { + Ok(interfaces) => { + let total_count = interfaces.len(); + ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Success", + NetworkInterfacesResponse { + interfaces, + total_count, + }, + )), + ) + } + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ), + } +} + +/// GET /api/network/connection-addresses +pub async fn get_network_connection_addresses( + State(dbus_conn): State>, +) -> impl IntoResponse { + match read_network_interfaces(Some(&dbus_conn)).await { + Ok(interfaces) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Success", + connection_addresses_from_interfaces(&interfaces), + )), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ), + } +} + +/// GET /api/device-network/ddns/config +pub async fn get_device_ddns_config_handler(State(app): State) -> impl IntoResponse { + let config = app.config_manager.get_ddns_config(); + let access_secret_set = !config.access_secret.trim().is_empty(); + ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Success", + ddns_config_response(config, access_secret_set), + )), + ) +} + +/// POST /api/device-network/ddns/config +pub async fn set_device_ddns_config_handler( + State(app): State, + Json(mut payload): Json, +) -> impl IntoResponse { + let current = app.config_manager.get_ddns_config(); + if is_masked_secret(&payload.access_id) { + payload.access_id = current.access_id; + } + if payload.access_secret.trim().is_empty() { + payload.access_secret = current.access_secret; + } else if is_masked_secret(&payload.access_secret) { + payload.access_secret = current.access_secret; + } + if payload.interval_seconds == 0 { + payload.interval_seconds = 300; + } + if payload.ttl == 0 { + payload.ttl = 600; + } + + match app.config_manager.set_ddns_config(payload.clone()) { + Ok(()) => { + let access_secret_set = !payload.access_secret.trim().is_empty(); + ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "DDNS config updated", + ddns_config_response(payload, access_secret_set), + )), + ) + } + Err(err) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + err + ))), + ), + } +} + +fn ddns_config_response( + mut config: crate::config::DdnsConfig, + access_secret_set: bool, +) -> serde_json::Value { + config.access_id = mask_secret(&config.access_id); + config.access_secret = mask_secret(&config.access_secret); + let mut value = serde_json::to_value(config).unwrap_or_else(|_| json!({})); + if let Some(object) = value.as_object_mut() { + object.insert("access_secret_set".to_string(), json!(access_secret_set)); + } + value +} + +fn mask_secret(value: &str) -> String { + let trimmed = value.trim(); + if trimmed.is_empty() { + return String::new(); + } + let prefix: String = trimmed.chars().take(3).collect(); + format!("{prefix}******") +} + +fn is_masked_secret(value: &str) -> bool { + value.contains('*') +} + +/// GET /api/device-network/ddns/status +pub async fn get_device_ddns_status_handler(State(app): State) -> impl IntoResponse { + let config = app.config_manager.get_ddns_config(); + let status = app.ddns_manager.status(&config).await; + ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Success", status)), + ) +} + +/// POST /api/device-network/ddns/sync +pub async fn sync_device_ddns_handler(State(app): State) -> impl IntoResponse { + match app + .ddns_manager + .sync_now(app.config_manager.clone(), app.notification_sender.clone()) + .await + { + Ok(data) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "DDNS sync completed", + data, + )), + ), + Err(err) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + err + ))), + ), + } +} + +/// GET /api/device-network/ddns/logs +pub async fn get_device_ddns_logs_handler(State(app): State) -> impl IntoResponse { + let logs = app.ddns_manager.logs().await; + ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Success", logs)), + ) +} + +/// POST /api/device-network/ddns/logs/clear +pub async fn clear_device_ddns_logs_handler(State(app): State) -> impl IntoResponse { + app.ddns_manager.clear_logs().await; + ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "DDNS logs cleared", + json!({}), + )), + ) +} + +/// GET /api/device-network/wlan/status +pub async fn get_device_wlan_status_handler() -> impl IntoResponse { + match crate::device_network::wlan_status().await { + Ok(data) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Success", data)), + ), + Err(err) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + err + ))), + ), + } +} + +/// POST /api/device-network/wlan/enabled +pub async fn set_device_wlan_enabled_handler( + Json(payload): Json, +) -> impl IntoResponse { + match crate::device_network::wlan_set_enabled(payload).await { + Ok(data) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "WLAN state updated", + data, + )), + ), + Err(err) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + err + ))), + ), + } +} + +/// POST /api/device-network/wlan/scan +pub async fn scan_device_wlan_handler() -> impl IntoResponse { + match crate::device_network::wlan_scan().await { + Ok(data) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Success", data)), + ), + Err(err) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + err + ))), + ), + } +} + +/// GET /api/device-network/wlan/profiles +pub async fn get_device_wlan_profiles_handler() -> impl IntoResponse { + match crate::device_network::wlan_profiles().await { + Ok(data) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Success", data)), + ), + Err(err) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + err + ))), + ), + } +} + +/// POST /api/device-network/wlan/forget +pub async fn forget_device_wlan_handler( + Json(payload): Json, +) -> impl IntoResponse { + match crate::device_network::wlan_forget(payload).await { + Ok(data) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "WLAN profile forgotten", + data, + )), + ), + Err(err) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + err + ))), + ), + } +} + +/// POST /api/device-network/wlan/connect +pub async fn connect_device_wlan_handler( + State(app): State, + Json(payload): Json, +) -> impl IntoResponse { + let target_ssid = payload.ssid.clone(); + let previous = crate::device_network::wlan_status().await.ok(); + match crate::device_network::wlan_connect(payload).await { + Ok(data) => { + if data.connected { + app.system_event_emitter + .emit_code( + system_event_codes::DEVICE_NETWORK_WLAN_CONNECTED, + system_event_severity::INFO, + system_event_status::SUCCEEDED, + data.ssid.clone().unwrap_or_else(|| target_ssid.clone()), + "WLAN 已连接", + ) + .await; + let previous_ssid = previous.and_then(|status| status.ssid); + if previous_ssid.is_some() && previous_ssid != data.ssid && data.ssid.is_some() { + app.system_event_emitter + .emit_code( + system_event_codes::DEVICE_NETWORK_WLAN_SSID_CHANGED, + system_event_severity::INFO, + system_event_status::CHANGED, + data.ssid.clone().unwrap_or_default(), + "WLAN SSID 已变化", + ) + .await; + } + } + ( + StatusCode::OK, + Json(ApiResponse::success_with_message("WLAN connected", data)), + ) + } + Err(err) => { + app.system_event_emitter + .emit_code( + system_event_codes::DEVICE_NETWORK_WLAN_CONNECT_FAILED, + system_event_severity::WARNING, + system_event_status::FAILED, + target_ssid, + format!("WLAN 连接失败: {err}"), + ) + .await; + ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + err + ))), + ) + } + } +} + +/// POST /api/device-network/wlan/disconnect +pub async fn disconnect_device_wlan_handler(State(app): State) -> impl IntoResponse { + let previous = crate::device_network::wlan_status().await.ok(); + match crate::device_network::wlan_disconnect().await { + Ok(data) => { + if previous + .as_ref() + .map(|status| status.connected) + .unwrap_or(false) + && !data.connected + { + app.system_event_emitter + .emit_code( + system_event_codes::DEVICE_NETWORK_WLAN_DISCONNECTED, + system_event_severity::INFO, + system_event_status::CHANGED, + previous.and_then(|status| status.ssid).unwrap_or_default(), + "WLAN 已断开", + ) + .await; + } + ( + StatusCode::OK, + Json(ApiResponse::success_with_message("WLAN disconnected", data)), + ) + } + Err(err) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + err + ))), + ), + } +} + +/// POST /api/device-network/wlan/profile +pub async fn save_device_wlan_profile_handler( + Json(payload): Json, +) -> impl IntoResponse { + match crate::device_network::wlan_save_profile(payload).await { + Ok(data) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "WLAN profile updated", + data, + )), + ), + Err(err) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + err + ))), + ), + } +} + +/// GET /api/network/signal-strength +pub async fn get_signal_strength_handler(State(conn): State>) -> impl IntoResponse { + match get_signal_strength(&conn).await { + Ok(data) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Success", data)), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ), + } +} + +// ============ 数据连接 ============ + +/// GET /api/data +pub async fn get_data_status(State(app): State) -> impl IntoResponse { + if app.data_user_disabled.load(Ordering::SeqCst) { + return ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Success", + DataConnectionResponse { active: false }, + )), + ); + } + + match get_data_connection_status(&app.dbus_conn).await { + Ok(active) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Success", + DataConnectionResponse { active }, + )), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ), + } +} + +/// POST /api/data +pub async fn set_data_status( + State(app): State, + Json(payload): Json, +) -> impl IntoResponse { + let previous_active = !app.data_user_disabled.load(Ordering::SeqCst); + let allow_roaming = app.config_manager.get_roaming_allowed(); + let apn_config = app.config_manager.get_apn_config(); + match set_data_connection_with_apn( + &app.dbus_conn, + payload.active, + allow_roaming, + Some(&apn_config), + ) + .await + { + Ok(_) => { + if let Err(err) = app.config_manager.set_data_enabled(payload.active) { + return ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed to save data switch state: {}", + err + ))), + ); + } + app.data_user_disabled + .store(!payload.active, Ordering::SeqCst); + if previous_active != payload.active { + app.system_event_emitter + .emit_code( + system_event_codes::CELLULAR_DATA_ENABLED_CHANGED, + system_event_severity::INFO, + system_event_status::CHANGED, + "cellular_data", + if payload.active { + "蜂窝数据开关已开启" + } else { + "蜂窝数据开关已关闭" + }, + ) + .await; + } + // 同步 NM autoconnect 状态,防止用户关闭数据后 NM 自动重连 + tokio::spawn(async move { + if let Ok(profile) = find_nm_modem_connection_pub().await { + let _ = nm_set_autoconnect_pub(&profile, payload.active).await; + } + }); + ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Data connection updated", + DataConnectionResponse { + active: payload.active, + }, + )), + ) + } + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ), + } +} + +pub async fn restart_baseband_handler(State(app): State) -> impl IntoResponse { + let auto_connect_data = !app.data_user_disabled.load(Ordering::SeqCst); + let allow_roaming = app.config_manager.get_roaming_allowed(); + let apn_config = app.config_manager.get_apn_config(); + match restart_baseband( + &app.dbus_conn, + auto_connect_data, + allow_roaming, + Some(apn_config), + ) + .await + { + Ok(data) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Baseband restarted", + data, + )), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "重启基带失败:{e}", + ))), + ), + } +} + +pub async fn get_baseband_restart_status_handler() -> impl IntoResponse { + ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Success", + get_baseband_restart_progress(), + )), + ) +} + +/// GET /api/roaming +pub async fn get_roaming_status_handler(State(app): State) -> impl IntoResponse { + let roaming_allowed = app.config_manager.get_roaming_allowed(); + match get_is_roaming_mm(&app.dbus_conn).await { + Ok(is_roaming) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Success", + RoamingResponse { + roaming_allowed, + is_roaming, + }, + )), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ), + } +} + +/// POST /api/roaming +pub async fn set_roaming_status_handler( + State(app): State, + Json(payload): Json, +) -> impl IntoResponse { + let previous_allowed = app.config_manager.get_roaming_allowed(); + match apply_roaming_policy(&app.dbus_conn, &app.config_manager, payload.allowed).await { + Ok(_) => { + let roaming_allowed = app.config_manager.get_roaming_allowed(); + if previous_allowed != roaming_allowed { + app.system_event_emitter + .emit_code( + system_event_codes::CELLULAR_ROAMING_ALLOWED_CHANGED, + system_event_severity::INFO, + system_event_status::CHANGED, + "roaming", + if roaming_allowed { + "允许漫游已开启" + } else { + "允许漫游已关闭" + }, + ) + .await; + } + match get_is_roaming_mm(&app.dbus_conn).await { + Ok(is_roaming) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Success", + RoamingResponse { + roaming_allowed, + is_roaming, + }, + )), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ), + } + } + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ), + } +} + +/// POST /api/airplane-mode +pub async fn set_airplane_mode_handler( + State(app): State, + Json(payload): Json, +) -> impl IntoResponse { + let previous_enabled = get_airplane_mode(&app.dbus_conn) + .await + .ok() + .map(|status| status.enabled); + if payload.enabled { + app.airplane_mode_requested.store(true, Ordering::SeqCst); + } + + match set_airplane_mode(&app.dbus_conn, payload.enabled).await { + Ok(_) => { + app.airplane_mode_requested + .store(payload.enabled, Ordering::SeqCst); + match get_airplane_mode(&app.dbus_conn).await { + Ok(status) => { + if previous_enabled != Some(status.enabled) { + app.system_event_emitter + .emit_code( + system_event_codes::CELLULAR_AIRPLANE_MODE_CHANGED, + system_event_severity::INFO, + system_event_status::CHANGED, + "airplane_mode", + if status.enabled { + "飞行模式已开启" + } else { + "飞行模式已关闭" + }, + ) + .await; + } + ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + if payload.enabled { + "Airplane mode enabled" + } else { + "Airplane mode disabled" + }, + status, + )), + ) + } + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ), + } + } + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ), + } +} + +/// GET /api/airplane-mode +pub async fn get_airplane_mode_handler(State(conn): State>) -> impl IntoResponse { + match get_airplane_mode(&conn).await { + Ok(status) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Success", status)), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ), + } +} + +// ============ 短信功能 ============ + +use crate::db::{Database, EsimEuiccCacheEntry, EsimProfileCacheEntry}; + +fn schedule_sms_db_maintenance(app: &AppState, deleted: usize) { + if deleted < SMS_DB_MAINTENANCE_DELETE_THRESHOLD { + return; + } + + if app.sms_db_maintenance_pending.swap(true, Ordering::SeqCst) { + info!( + deleted, + threshold = SMS_DB_MAINTENANCE_DELETE_THRESHOLD, + "SMS database maintenance already scheduled" + ); + return; + } + + let db = Arc::clone(&app.database); + let pending = Arc::clone(&app.sms_db_maintenance_pending); + tokio::spawn(async move { + info!( + deleted, + delay_secs = SMS_DB_MAINTENANCE_DELAY_SECS, + "SMS database maintenance scheduled" + ); + tokio::time::sleep(tokio::time::Duration::from_secs( + SMS_DB_MAINTENANCE_DELAY_SECS, + )) + .await; + + let result = tokio::task::spawn_blocking(move || db.vacuum()).await; + match result { + Ok(Ok(())) => info!("SMS database maintenance completed"), + Ok(Err(err)) => warn!(error = %err, "SMS database maintenance failed"), + Err(err) => warn!(error = %err, "SMS database maintenance task failed"), + } + pending.store(false, Ordering::SeqCst); + }); +} + +/// POST /api/sms/send +pub async fn send_sms_handler( + State(conn): State>, + State(db): State>, + Json(payload): Json, +) -> impl IntoResponse { + match send_sms(&conn, &payload.phone_number, &payload.content).await { + Ok(path) => { + // 存入数据库 + let _ = db.insert_sms( + "outgoing", + &payload.phone_number, + &payload.content, + "sent", + None, + ); + ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "SMS sent", + json!({ "path": path }), + )), + ) + } + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed to send SMS: {}", + e + ))), + ), + } +} + +/// GET /api/sms/list +pub async fn get_sms_list_handler( + State(db): State>, + Query(params): Query, +) -> (StatusCode, Json>) { + let limit = if params.limit > 0 { params.limit } else { 50 }; + let offset = if params.offset >= 0 { params.offset } else { 0 }; + let direction = params + .direction + .as_deref() + .filter(|value| matches!(*value, "incoming" | "outgoing")); + + match db.get_sms_messages(limit, offset, direction) { + Ok(messages) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Success", + SmsListResponse { messages }, + )), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ), + } +} + +/// GET /api/sms/conversation +pub async fn get_sms_conversation_handler( + State(db): State>, + Query(params): Query, +) -> (StatusCode, Json>) { + let limit = if params.limit > 0 { params.limit } else { 50 }; + match db.get_sms_conversation(¶ms.phone_number, limit) { + Ok(messages) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Success", + SmsListResponse { messages }, + )), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ), + } +} + +/// GET /api/sms/stats +pub async fn get_sms_stats_handler( + State(db): State>, +) -> (StatusCode, Json>) { + match db.get_sms_stats() { + Ok(stats) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Success", stats)), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ), + } +} + +/// POST /api/sms/clear +pub async fn clear_sms_handler( + State(app): State, +) -> (StatusCode, Json>) { + let deleted = app + .database + .get_sms_stats() + .map(|stats| stats.total.max(0) as usize) + .unwrap_or(SMS_DB_MAINTENANCE_DELETE_THRESHOLD); + + match app.database.clear_all_sms() { + Ok(_) => { + schedule_sms_db_maintenance(&app, deleted); + ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "All SMS cleared", + json!({ "deleted": deleted }), + )), + ) + } + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::error(format!("Failed: {}", e))), + ), + } +} + +/// DELETE /api/sms/message/{id} +pub async fn delete_sms_message_handler( + State(db): State>, + Path(id): Path, +) -> (StatusCode, Json>) { + match db.delete_sms(id) { + Ok(deleted) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "SMS deleted", + json!({ "deleted": deleted }), + )), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::error(format!("Failed: {}", e))), + ), + } +} + +/// DELETE /api/sms/conversation/{phone_number} +pub async fn delete_sms_conversation_handler( + State(app): State, + Path(phone_number): Path, +) -> (StatusCode, Json>) { + match app.database.delete_sms_conversation(&phone_number) { + Ok(deleted) => { + schedule_sms_db_maintenance(&app, deleted); + ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "SMS conversation deleted", + json!({ "deleted": deleted }), + )), + ) + } + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::error(format!("Failed: {}", e))), + ), + } +} + +/// POST /api/sms/batch-delete +pub async fn delete_sms_batch_handler( + State(app): State, + Json(payload): Json, +) -> (StatusCode, Json>) { + if payload.ids.is_empty() && payload.phone_numbers.is_empty() { + return (StatusCode::OK, Json(ApiResponse::error("No SMS selected"))); + } + + match app + .database + .delete_sms_batch(&payload.ids, &payload.phone_numbers) + { + Ok(deleted) => { + schedule_sms_db_maintenance(&app, deleted); + ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "SMS batch deleted", + json!({ "deleted": deleted }), + )), + ) + } + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::error(format!("Failed: {}", e))), + ), + } +} + +// ============ 系统信息 ============ + +/// 读取温度传感器数据 +// ============ 电话功能 ============ + +async fn track_call_start( + app: &AppState, + path: &str, + direction: &str, + phone_number: &str, + answered: bool, +) { + if let Ok(id) = app.database.insert_call(direction, phone_number, answered) { + let mut active = app.active_calls.lock().await; + active.insert( + path.to_string(), + crate::state::ActiveCallRecord { + id, + answered_at: answered.then(std::time::Instant::now), + answered, + }, + ); + } +} + +async fn mark_tracked_call_answered(app: &AppState, path: &str) { + let mut active = app.active_calls.lock().await; + if let Some(record) = active.get_mut(path) { + record.answered = true; + if record.answered_at.is_none() { + record.answered_at = Some(std::time::Instant::now()); + } + } +} + +async fn finish_tracked_call(app: &AppState, path: &str, answered_now: bool) { + let mut record = { + let mut active = app.active_calls.lock().await; + active.remove(path) + }; + if let Some(ref mut record) = record { + if answered_now && record.answered_at.is_none() { + record.answered_at = Some(std::time::Instant::now()); + } + let duration = record + .answered_at + .map(|at| at.elapsed().as_secs() as i64) + .unwrap_or(0); + let _ = app + .database + .update_call_end(record.id, duration, record.answered || answered_now); + } +} + +pub async fn get_calls_handler(State(app): State) -> impl IntoResponse { + match list_current_calls(&app.dbus_conn).await { + Ok(data) => { + for call in &data.calls { + if matches!(call.state.as_str(), "active" | "held") { + mark_tracked_call_answered(&app, &call.path).await; + } + } + ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Success", data)), + ) + } + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ), + } +} + +pub async fn dial_call_handler( + State(app): State, + Json(payload): Json, +) -> impl IntoResponse { + let phone_number = payload.phone_number.trim().to_string(); + if phone_number.is_empty() { + return ( + StatusCode::OK, + Json(ApiResponse::::error( + "Phone number is required", + )), + ); + } + match make_call(&app.dbus_conn, &phone_number).await { + Ok(path) => { + track_call_start(&app, &path, "outgoing", &phone_number, false).await; + ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Call started", + json!({ "path": path }), + )), + ) + } + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed to dial: {}", + e + ))), + ), + } +} + +pub async fn hangup_call_handler( + State(app): State, + Json(payload): Json, +) -> impl IntoResponse { + let before = get_call_by_path(&app.dbus_conn, &payload.path).await.ok(); + match hangup_call(&app.dbus_conn, &payload.path).await { + Ok(()) => { + let answered = before + .as_ref() + .map(|call| call.state == "active" || call.state == "held") + .unwrap_or(false); + finish_tracked_call(&app, &payload.path, answered).await; + if let Some(call) = before { + if call.direction == "incoming" + && matches!(call.state.as_str(), "incoming" | "waiting") + { + let _ = app + .database + .insert_call("missed", &call.phone_number, false); + } + } + ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Call hung up", json!({}))), + ) + } + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed to hang up: {}", + e + ))), + ), + } +} + +pub async fn hangup_all_calls_handler(State(app): State) -> impl IntoResponse { + let before = list_current_calls(&app.dbus_conn).await.ok(); + match hangup_all_calls(&app.dbus_conn).await { + Ok(()) => { + if let Some(list) = before { + for call in list.calls { + let answered = call.state == "active" || call.state == "held"; + finish_tracked_call(&app, &call.path, answered).await; + if call.direction == "incoming" + && matches!(call.state.as_str(), "incoming" | "waiting") + { + let _ = app + .database + .insert_call("missed", &call.phone_number, false); + } + } + } + ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "All calls hung up", + json!({}), + )), + ) + } + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed to hang up calls: {}", + e + ))), + ), + } +} + +pub async fn answer_call_handler( + State(app): State, + Json(payload): Json, +) -> impl IntoResponse { + let before = get_call_by_path(&app.dbus_conn, &payload.path).await.ok(); + match answer_call(&app.dbus_conn, &payload.path).await { + Ok(()) => { + if let Some(call) = before { + track_call_start(&app, &payload.path, "incoming", &call.phone_number, true).await; + mark_tracked_call_answered(&app, &payload.path).await; + } + ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Call answered", + json!({}), + )), + ) + } + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed to answer call: {}", + e + ))), + ), + } +} + +pub async fn get_call_history_handler( + State(db): State>, + Query(params): Query, +) -> (StatusCode, Json>) { + let limit = if params.limit > 0 { params.limit } else { 50 }; + let offset = if params.offset >= 0 { params.offset } else { 0 }; + match (db.get_call_history(limit, offset), db.get_call_stats()) { + (Ok(records), Ok(stats)) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Success", + CallHistoryResponse { records, stats }, + )), + ), + (Err(e), _) | (_, Err(e)) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ), + } +} + +pub async fn delete_call_history_handler( + State(db): State>, + Path(id): Path, +) -> (StatusCode, Json>) { + match db.delete_call(id) { + Ok(()) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Call record deleted", + json!({}), + )), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::error(format!("Failed: {}", e))), + ), + } +} + +pub async fn clear_call_history_handler( + State(db): State>, +) -> (StatusCode, Json>) { + match db.clear_all_calls() { + Ok(()) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Call history cleared", + json!({}), + )), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::error(format!("Failed: {}", e))), + ), + } +} + +pub async fn get_call_settings_handler(State(conn): State>) -> impl IntoResponse { + match get_call_settings(&conn).await { + Ok(data) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Success", data)), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ), + } +} + +pub async fn set_call_settings_handler( + State(conn): State>, + Json(payload): Json, +) -> impl IntoResponse { + if payload.property != "VoiceCallWaiting" { + return ( + StatusCode::OK, + Json(ApiResponse::::error( + "Only VoiceCallWaiting is supported by ModemManager", + )), + ); + } + let enabled = matches!(payload.value.as_str(), "enabled" | "on" | "true" | "1"); + match set_call_waiting(&conn, enabled).await { + Ok(()) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Call setting updated", + json!({}), + )), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed to update call setting: {}", + e + ))), + ), + } +} + +pub async fn get_call_volume_handler() -> impl IntoResponse { + ( + StatusCode::OK, + Json(ApiResponse::::error( + "Call volume control is not exposed by ModemManager on this backend", + )), + ) +} + +pub async fn set_call_volume_handler( + Json(payload): Json, +) -> impl IntoResponse { + let _ = ( + payload.speaker_volume, + payload.microphone_volume, + payload.muted, + ); + ( + StatusCode::OK, + Json(ApiResponse::::error( + "Call volume control is not exposed by ModemManager on this backend", + )), + ) +} + +pub async fn get_call_forwarding_handler() -> impl IntoResponse { + ( + StatusCode::OK, + Json(ApiResponse::::error( + "Call forwarding is not exposed by ModemManager on this backend", + )), + ) +} + +pub async fn set_call_forwarding_handler( + Json(payload): Json, +) -> impl IntoResponse { + let _ = (payload.forward_type, payload.number, payload.timeout); + ( + StatusCode::OK, + Json(ApiResponse::::error( + "Call forwarding is not exposed by ModemManager on this backend", + )), + ) +} + +pub async fn get_ims_status_handler() -> impl IntoResponse { + ( + StatusCode::OK, + Json(ApiResponse::::error( + "IMS status is not exposed by ModemManager on this backend", + )), + ) +} + +pub async fn get_voicemail_status_handler() -> impl IntoResponse { + ( + StatusCode::OK, + Json(ApiResponse::::error( + "Voicemail status is not exposed by ModemManager on this backend", + )), + ) +} + +pub(crate) fn temperature_sensor_label(sensor_type: &str, zone: &str) -> String { + let source = if sensor_type.trim().is_empty() { + if zone.trim().is_empty() { + "unknown" + } else { + zone.trim() + } + } else { + sensor_type.trim() + }; + let normalized = source.to_ascii_lowercase().replace('_', "-"); + + if ["modem", "baseband", "wwan", "qmi", "mhi"] + .iter() + .any(|pattern| normalized.contains(pattern)) + { + return "基带".to_string(); + } + if ["gpu", "adreno"] + .iter() + .any(|pattern| normalized.contains(pattern)) + { + return "GPU".to_string(); + } + if ["camera", "cam", "isp"] + .iter() + .any(|pattern| normalized.contains(pattern)) + { + return "摄像头".to_string(); + } + if ["wifi", "wlan"] + .iter() + .any(|pattern| normalized.contains(pattern)) + { + return "Wi-Fi".to_string(); + } + if ["battery", "batt"] + .iter() + .any(|pattern| normalized.contains(pattern)) + { + return "电池".to_string(); + } + if ["charger", "charge"] + .iter() + .any(|pattern| normalized.contains(pattern)) + { + return "充电".to_string(); + } + if ["pmic", "power"] + .iter() + .any(|pattern| normalized.contains(pattern)) + { + return "电源管理".to_string(); + } + if ["soc", "tsens"] + .iter() + .any(|pattern| normalized.contains(pattern)) + { + return "SoC".to_string(); + } + if ["skin", "shell", "case"] + .iter() + .any(|pattern| normalized.contains(pattern)) + { + return "外壳".to_string(); + } + if ["ambient", "board"] + .iter() + .any(|pattern| normalized.contains(pattern)) + { + return "环境".to_string(); + } + + if let Some((first, second)) = extract_number_range_after(&normalized, "cpu") { + return second + .map(|second| format!("CPU {first}-{second}")) + .unwrap_or_else(|| format!("CPU {first}")); + } + if normalized.contains("cpu") { + return "CPU".to_string(); + } + + if let Some((first, second)) = extract_number_range_after(&normalized, "core") { + return second + .map(|second| format!("核心 {first}-{second}")) + .unwrap_or_else(|| format!("核心 {first}")); + } + if normalized.contains("core") { + return "核心".to_string(); + } + + let cleaned = source + .replace(|ch: char| matches!(ch, '-' | '_' | ' '), " ") + .split_whitespace() + .filter(|part| { + !matches!( + part.to_ascii_lowercase().as_str(), + "thermal" | "therm" | "temperature" | "temp" | "sensor" | "zone" + ) + }) + .collect::>() + .join(" "); + + if cleaned.is_empty() { + source.to_string() + } else { + cleaned + } +} + +fn extract_number_range_after(value: &str, prefix: &str) -> Option<(String, Option)> { + let start = value.find(prefix)? + prefix.len(); + let chars = value[start..].char_indices(); + let mut first_start = None; + for (index, ch) in chars { + if ch.is_ascii_digit() { + first_start = Some(start + index); + break; + } + } + let first_start = first_start?; + let first_end = value[first_start..] + .char_indices() + .find_map(|(index, ch)| (!ch.is_ascii_digit()).then_some(first_start + index)) + .unwrap_or(value.len()); + let first = value[first_start..first_end].to_string(); + + let after_first = &value[first_end..]; + let mut second_start = None; + for (index, ch) in after_first.char_indices() { + if ch.is_ascii_digit() { + second_start = Some(first_end + index); + break; + } + if ch.is_ascii_alphabetic() { + break; + } + } + let Some(second_start) = second_start else { + return Some((first, None)); + }; + let second_end = value[second_start..] + .char_indices() + .find_map(|(index, ch)| (!ch.is_ascii_digit()).then_some(second_start + index)) + .unwrap_or(value.len()); + Some((first, Some(value[second_start..second_end].to_string()))) +} + +pub(crate) fn read_temperature_sensors() -> Vec { + use std::fs; + use std::path::Path; + + let thermal_path = Path::new("/sys/class/thermal"); + let mut sensors = Vec::new(); + + if let Ok(entries) = fs::read_dir(thermal_path) { + for entry in entries.flatten() { + let file_name = entry.file_name(); + let name = file_name.to_string_lossy(); + + if name.starts_with("thermal_zone") { + let zone_path = entry.path(); + let sensor_type = fs::read_to_string(zone_path.join("type")) + .map(|s| s.trim().to_string()) + .unwrap_or_default(); + let temperature = fs::read_to_string(zone_path.join("temp")) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .map(|t| t as f64 / 1000.0) + .unwrap_or(0.0); + + let label = temperature_sensor_label(&sensor_type, &name); + sensors.push(ThermalZone { + zone: name.to_string(), + sensor_type, + label, + temperature, + }); + } + } + } + sensors.sort_by(|a, b| a.zone.cmp(&b.zone)); + sensors +} + +static SYSTEM_STATS_SNAPSHOT: OnceLock>>> = OnceLock::new(); +const SYSTEM_STATS_LOW_FREQUENCY_REFRESH_SECS: u64 = 10; + +#[derive(Default)] +struct SystemStatsSamplerState { + previous_network: HashMap, + last_low_frequency_refresh: Option, + memory: MemoryInfo, + disk: Vec, + uptime: UptimeInfo, + system_info: SystemInfo, + temperature: Vec, +} + +fn system_stats_snapshot() -> Arc>> { + Arc::clone(SYSTEM_STATS_SNAPSHOT.get_or_init(|| Arc::new(RwLock::new(None)))) +} + +async fn collect_system_stats_snapshot( + dbus_conn: &Connection, + state: &mut SystemStatsSamplerState, + interval_seconds: f64, +) -> Result { + let interfaces = + get_active_interfaces().map_err(|e| format!("Failed to get interfaces: {}", e))?; + + let mut speed_data = Vec::new(); + let elapsed = interval_seconds.max(0.001); + for iface in &interfaces { + if let Ok((rx, tx)) = read_interface_stats(iface, Some(dbus_conn)).await { + let (rx_speed, tx_speed) = state + .previous_network + .get(iface) + .map(|(prev_rx, prev_tx)| { + ( + (rx.saturating_sub(*prev_rx) as f64 / elapsed) as u64, + (tx.saturating_sub(*prev_tx) as f64 / elapsed) as u64, + ) + }) + .unwrap_or((0, 0)); + speed_data.push(NetworkSpeed { + interface: iface.clone(), + rx_bytes_per_sec: rx_speed, + tx_bytes_per_sec: tx_speed, + total_rx_bytes: rx, + total_tx_bytes: tx, + }); + state.previous_network.insert(iface.clone(), (rx, tx)); + } + } + state + .previous_network + .retain(|iface, _| interfaces.iter().any(|current| current == iface)); + + let cpu_usage = sample_cpu_usage().await.unwrap_or(0.0); + let mut cpu_load = read_cpu_load_sync().unwrap_or_default(); + cpu_load.load_percent = cpu_usage; + + let should_refresh_low_frequency = state + .last_low_frequency_refresh + .map(|last| last.elapsed() >= Duration::from_secs(SYSTEM_STATS_LOW_FREQUENCY_REFRESH_SECS)) + .unwrap_or(true); + if should_refresh_low_frequency { + let (total, available, cached, buffers) = read_memory_info()?; + let used = total.saturating_sub(available); + let used_percent = if total > 0 { + (used as f64 / total as f64) * 100.0 + } else { + 0.0 + }; + let (uptime, idle) = read_uptime()?; + state.memory = MemoryInfo { + total_bytes: total, + available_bytes: available, + used_bytes: used, + used_percent, + cached_bytes: cached, + buffers_bytes: buffers, + }; + state.disk = read_disk_info(); + state.uptime = UptimeInfo { + uptime_seconds: uptime, + idle_seconds: idle, + uptime_formatted: format_uptime(uptime), + }; + state.system_info = read_system_info()?; + state.temperature = read_temperature_sensors(); + state.last_low_frequency_refresh = Some(Instant::now()); + } + + Ok(SystemStatsResponse { + network_speed: NetworkSpeedResponse { + interfaces: speed_data, + interval_seconds: elapsed, + }, + memory: state.memory.clone(), + disk: state.disk.clone(), + cpu_load, + uptime: state.uptime.clone(), + system_info: state.system_info.clone(), + temperature: state.temperature.clone(), + }) +} + +pub fn spawn_system_stats_sampler(dbus_conn: Arc) { + let snapshot = system_stats_snapshot(); + tokio::spawn(async move { + let mut sampler_state = SystemStatsSamplerState::default(); + let mut last_sample = Instant::now(); + loop { + let elapsed = last_sample.elapsed().as_secs_f64().max(1.0); + last_sample = Instant::now(); + match collect_system_stats_snapshot(&dbus_conn, &mut sampler_state, elapsed).await { + Ok(stats) => { + *snapshot.write().await = Some(stats); + } + Err(err) => warn!(error = %err, "Failed to sample system stats"), + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + }); +} + +/// GET /api/stats +pub async fn get_system_stats(State(_dbus_conn): State>) -> impl IntoResponse { + let snapshot = system_stats_snapshot(); + if let Some(data) = snapshot.read().await.clone() { + return ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Success", data)), + ); + } + + ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "No system stats sample yet", + SystemStatsResponse::default(), + )), + ) +} + +/// GET /api/stats/cpu +pub async fn get_cpu_info() -> impl IntoResponse { + match read_cpu_info() { + Ok(info) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Success", info)), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!("Failed: {}", e))), + ), + } +} + +/// GET /api/connectivity +pub async fn get_connectivity_check() -> (StatusCode, Json>) +{ + // 两个 ping 并行执行,超时从 2s 缩短到 1s + let (ipv4_result, ipv6_result) = tokio::join!( + async_ping_host("223.5.5.5", false), + async_ping_host("2400:3200::1", true), + ); + ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Connectivity check completed", + ConnectivityCheckResponse { + ipv4: ipv4_result, + ipv6: ipv6_result, + }, + )), + ) +} + +pub(crate) async fn async_ping_host(target: &str, is_ipv6: bool) -> PingResult { + let cmd = if is_ipv6 { "ping6" } else { "ping" }; + let output = tokio::process::Command::new(cmd) + .args(["-c", "1", "-W", "1", target]) + .output() + .await; + match output { + Ok(result) => { + if result.status.success() { + let stdout = String::from_utf8_lossy(&result.stdout); + let latency = parse_ping_latency(&stdout); + PingResult { + success: true, + latency_ms: latency, + target: target.to_string(), + error: None, + } + } else { + let stderr = String::from_utf8_lossy(&result.stderr); + PingResult { + success: false, + latency_ms: None, + target: target.to_string(), + error: Some(if stderr.is_empty() { + "Unreachable".to_string() + } else { + stderr.trim().to_string() + }), + } + } + } + Err(e) => PingResult { + success: false, + latency_ms: None, + target: target.to_string(), + error: Some(format!("Failed: {}", e)), + }, + } +} + +fn parse_ping_latency(output: &str) -> Option { + for line in output.lines() { + if let Some(time_pos) = line.find("time=") { + let after_time = &line[time_pos + 5..]; + let num_str: String = after_time + .chars() + .take_while(|c| c.is_ascii_digit() || *c == '.') + .collect(); + if let Ok(latency) = num_str.parse::() { + return Some(latency); + } + } + } + None +} + +/// POST /api/system/reboot +pub async fn system_reboot( + State(app): State, + Json(payload): Json, +) -> impl IntoResponse { + let delay = payload.delay_seconds; + app.system_event_emitter + .emit_code( + system_event_codes::SYSTEM_SERVICE_REBOOT_REQUESTED, + system_event_severity::WARNING, + system_event_status::TRIGGERED, + "system", + format!("用户触发系统重启,延迟 {} 秒执行", delay), + ) + .await; + let system_events = Arc::clone(&app.system_event_emitter); + tokio::spawn(async move { + run_safe_os_reboot_sequence(delay, system_events).await; + }); + ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + format!("System will perform safe OS reboot in {} seconds", delay), + json!({ "delay_seconds": delay }), + )), + ) +} + +pub async fn run_safe_os_reboot_sequence( + delay_seconds: u32, + system_events: Arc, +) { + if delay_seconds > 0 { + tokio::time::sleep(tokio::time::Duration::from_secs(delay_seconds as u64)).await; + } + + info!("Starting safe OS reboot sequence"); + + if let Some(message) = + run_reboot_prep_command("disable modem radio", "mmcli", &["-m", "0", "-d"], false) + { + system_events + .emit_code( + system_event_codes::SYSTEM_SERVICE_REBOOT_PREP_FAILED, + system_event_severity::WARNING, + system_event_status::FAILED, + "disable modem radio", + message, + ) + .await; + } + if let Some(message) = run_reboot_prep_command( + "stop ModemManager IPC service", + "systemctl", + &["stop", "ModemManager"], + false, + ) { + system_events + .emit_code( + system_event_codes::SYSTEM_SERVICE_REBOOT_PREP_FAILED, + system_event_severity::WARNING, + system_event_status::FAILED, + "stop ModemManager IPC service", + message, + ) + .await; + } + let _ = run_reboot_prep_command("stop qmi-proxy", "killall", &["qmi-proxy"], true); + cleanup_modemmanager_runtime_cache(); + if let Some(message) = run_reboot_prep_command("flush filesystem cache", "sync", &[], false) { + system_events + .emit_code( + system_event_codes::SYSTEM_SERVICE_REBOOT_PREP_FAILED, + system_event_severity::WARNING, + system_event_status::FAILED, + "flush filesystem cache", + message, + ) + .await; + } + + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + + info!("Safe OS reboot preparation complete, executing reboot"); + if let Err(err) = Command::new("reboot").output() { + error!(error = %err, "Failed to execute reboot command"); + } +} + +fn run_reboot_prep_command( + label: &str, + program: &str, + args: &[&str], + allow_failure: bool, +) -> Option { + match Command::new(program).args(args).output() { + Ok(output) if output.status.success() => { + info!(step = label, "Safe OS reboot step completed"); + None + } + Ok(output) => { + let severity = if allow_failure { + "optional" + } else { + "required" + }; + warn_reboot_prep_failure(label, program, severity, &output); + if allow_failure { + None + } else { + Some(format!( + "重启预处理步骤失败: {label}; command={program}; status={}", + output.status + )) + } + } + Err(err) if allow_failure => { + warn!(step = label, command = program, error = %err, "Optional safe OS reboot step failed"); + None + } + Err(err) => { + warn!(step = label, command = program, error = %err, "Safe OS reboot step failed"); + Some(format!( + "重启预处理步骤失败: {label}; command={program}; error={err}" + )) + } + } +} + +fn cleanup_modemmanager_runtime_cache() { + const CACHE_DIR: &str = "/var/lib/ModemManager"; + + match fs::read_dir(CACHE_DIR) { + Ok(entries) => { + let mut removed = 0usize; + for entry in entries { + match entry { + Ok(entry) => { + let path = entry.path(); + let result = if path.is_dir() { + fs::remove_dir_all(&path) + } else { + fs::remove_file(&path) + }; + + match result { + Ok(()) => removed += 1, + Err(err) => warn!( + path = %path.display(), + error = %err, + "Failed to remove ModemManager runtime cache entry" + ), + } + } + Err(err) => warn!( + directory = CACHE_DIR, + error = %err, + "Failed to read ModemManager runtime cache entry" + ), + } + } + info!( + directory = CACHE_DIR, + removed, "ModemManager runtime cache cleanup completed" + ); + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + info!( + directory = CACHE_DIR, + "ModemManager runtime cache directory does not exist" + ); + } + Err(err) => { + warn!( + directory = CACHE_DIR, + error = %err, + "Failed to open ModemManager runtime cache directory" + ); + } + } +} + +fn warn_reboot_prep_failure(label: &str, program: &str, severity: &str, output: &Output) { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + warn!( + step = label, + command = program, + severity = severity, + status = %output.status, + stderr = %stderr, + stdout = %stdout, + "Safe OS reboot step returned non-zero status" + ); +} + +// ============ 通知配置 ============ + +pub async fn restart_service_handler(State(app): State) -> impl IntoResponse { + app.system_event_emitter + .emit_code( + system_event_codes::SYSTEM_SERVICE_SIMADMIN_RESTART_REQUESTED, + system_event_severity::WARNING, + system_event_status::TRIGGERED, + "simadmin", + "用户触发 SimAdmin 服务重启", + ) + .await; + tokio::spawn(async move { + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + let _ = Command::new("systemctl") + .args(["restart", "simadmin"]) + .output(); + }); + ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "SimAdmin service will restart", + json!({}), + )), + ) +} + +use crate::config::ConfigManager; +use crate::notification::NotificationSender; + +#[derive(Debug, Default, Deserialize)] +pub struct NotificationLogQuery { + #[serde(default, rename = "type")] + pub event_type: String, + #[serde(default)] + pub status: String, + #[serde(default)] + pub q: String, + #[serde(default)] + pub start_date: String, + #[serde(default)] + pub end_date: String, + #[serde(default = "default_notification_log_limit")] + pub limit: i64, + #[serde(default)] + pub offset: i64, +} + +#[derive(Debug, Default, Deserialize)] +pub struct NotificationLogClearRequest { + #[serde(default, rename = "type")] + pub event_type: String, + #[serde(default)] + pub status: String, + #[serde(default)] + pub start_date: String, + #[serde(default)] + pub end_date: String, +} + +fn default_notification_log_limit() -> i64 { + 50 +} + +/// GET /api/notifications/config +pub async fn get_notification_config_handler( + State(config_manager): State>, +) -> ( + StatusCode, + Json>, +) { + let config = config_manager.get_notifications(); + ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Success", config)), + ) +} + +/// POST /api/notifications/config +pub async fn set_notification_config_handler( + State(config_manager): State>, + Json(notification_config): Json, +) -> (StatusCode, Json>) { + match config_manager.set_notifications(notification_config) { + Ok(_) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Notification config updated", + json!({}), + )), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::error(format!("Failed: {}", e))), + ), + } +} + +/// POST /api/notifications/test/{channel} +pub async fn test_notification_channel_handler( + Path(channel): Path, + State(notification_sender): State>, +) -> ( + StatusCode, + Json>, +) { + match notification_sender.test_channel(&channel).await { + Ok(message) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Notification test successful", + WebhookTestResponse { + success: true, + message, + }, + )), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Notification test failed", + WebhookTestResponse { + success: false, + message: e, + }, + )), + ), + } +} + +// ============ OTA 更新 ============ + +/// GET /api/notifications/logs +pub async fn get_notification_logs_handler( + Query(query): Query, + State(database): State>, +) -> ( + StatusCode, + Json>, +) { + match database.get_notification_logs( + &query.event_type, + &query.status, + &query.q, + &query.start_date, + &query.end_date, + query.limit, + query.offset, + ) { + Ok(logs) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Success", logs)), + ), + Err(err) => ( + StatusCode::OK, + Json(ApiResponse::error(format!("Failed: {}", err))), + ), + } +} + +/// POST /api/notifications/logs/clear +pub async fn clear_notification_logs_handler( + State(database): State>, + payload: Option>, +) -> (StatusCode, Json>) { + let filters = payload.map(|Json(value)| value).unwrap_or_default(); + match database.clear_notification_logs( + &filters.event_type, + &filters.status, + &filters.start_date, + &filters.end_date, + ) { + Ok(deleted) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Notification logs cleared", + json!({ "deleted": deleted }), + )), + ), + Err(err) => ( + StatusCode::OK, + Json(ApiResponse::error(format!("Failed: {}", err))), + ), + } +} + +/// GET /api/ota/status +pub async fn get_ota_status_handler() -> impl IntoResponse { + let status = crate::ota::get_ota_status(); + ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Success", status)), + ) +} + +/// POST /api/ota/upload +pub async fn upload_ota_handler(body: axum::body::Bytes) -> impl IntoResponse { + match crate::ota::handle_ota_upload(&body) { + Ok(response) => { + let message = if response.validation.valid { + "OTA uploaded and validated" + } else { + "OTA uploaded but validation failed" + }; + ( + StatusCode::OK, + Json(ApiResponse::success_with_message(message, response)), + ) + } + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error( + format!("Failed: {}", e), + )), + ), + } +} + +/// POST /api/ota/latest-release +pub async fn get_latest_ota_release_handler( + Json(req): Json, +) -> impl IntoResponse { + let result: Result = async { + let include_builtin_proxies = req + .proxy_prefix + .as_ref() + .map(|prefix| !prefix.trim().is_empty()) + .unwrap_or(false); + let proxy_prefix = crate::ota::normalize_proxy_prefix(req.proxy_prefix); + let client = crate::ota::build_ota_http_client()?; + + crate::ota::fetch_latest_github_release(&client, &proxy_prefix, include_builtin_proxies) + .await + } + .await; + + match result { + Ok(release) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Success", release)), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}. GitHub may have rate-limited this request; try again later or enable a proxy.", + e + ))), + ), + } +} + +/// POST /api/ota/online-prepare +pub async fn prepare_online_ota_handler( + Json(req): Json, +) -> impl IntoResponse { + let result: Result = async { + let include_builtin_proxies = req + .proxy_prefix + .as_ref() + .map(|prefix| !prefix.trim().is_empty()) + .unwrap_or(false); + let proxy_prefix = crate::ota::normalize_proxy_prefix(req.proxy_prefix); + let client = crate::ota::build_ota_http_client()?; + + let release = crate::ota::fetch_latest_github_release( + &client, + &proxy_prefix, + include_builtin_proxies, + ) + .await?; + + let asset = crate::ota::supported_release_asset(&release) + .ok_or_else(|| "No supported OTA asset found in latest release".to_string())?; + + if asset.size > crate::ota::MAX_OTA_BYTES { + return Err(format!( + "OTA asset is too large: {} bytes exceeds {} bytes", + asset.size, + crate::ota::MAX_OTA_BYTES + )); + } + + let bytes = crate::ota::download_ota_asset_bytes( + &client, + &proxy_prefix, + include_builtin_proxies, + asset, + ) + .await?; + + crate::ota::handle_ota_upload(&bytes) + } + .await; + + match result { + Ok(response) => { + let message = if response.validation.valid { + "Online OTA downloaded and validated" + } else { + "Online OTA downloaded but validation failed" + }; + ( + StatusCode::OK, + Json(ApiResponse::success_with_message(message, response)), + ) + } + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error( + format!("Failed: {}", e), + )), + ), + } +} + +/// POST /api/ota/apply +pub async fn apply_ota_handler( + Json(req): Json, +) -> impl IntoResponse { + match crate::ota::apply_ota_update(req.restart_now) { + Ok(message) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + &message, + json!({ "applied": true }), + )), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ), + } +} + +/// POST /api/ota/cancel +pub async fn cancel_ota_handler() -> impl IntoResponse { + match crate::ota::cancel_pending_update() { + Ok(()) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Update cancelled", + json!({}), + )), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::::error(format!( + "Failed: {}", + e + ))), + ), + } +} + +fn default_log_limit() -> i64 { + 100 +} + +#[derive(Debug, Deserialize)] +pub struct AutomationLogQuery { + #[serde(default, rename = "type")] + pub task_type: String, + #[serde(default)] + pub status: String, + #[serde(default)] + pub q: String, + #[serde(default)] + pub start_date: String, + #[serde(default)] + pub end_date: String, + #[serde(default = "default_log_limit")] + pub limit: i64, + #[serde(default)] + pub offset: i64, +} + +#[derive(Debug, Deserialize, Default)] +pub struct AutomationLogClearRequest { + #[serde(default, rename = "type")] + pub task_type: String, + #[serde(default)] + pub status: String, + #[serde(default)] + pub start_date: String, + #[serde(default)] + pub end_date: String, +} + +/// GET /api/automation/config +pub async fn get_automation_config_handler( + State(config_manager): State>, +) -> ( + StatusCode, + Json>, +) { + let config = config_manager.get_automation_config(); + ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Success", config)), + ) +} + +/// POST /api/automation/config +pub async fn set_automation_config_handler( + State(config_manager): State>, + Json(config): Json, +) -> (StatusCode, Json>) { + match config_manager.set_automation_config(config) { + Ok(_) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Automation config updated", + json!({}), + )), + ), + Err(e) => ( + StatusCode::OK, + Json(ApiResponse::error(format!("Failed: {}", e))), + ), + } +} + +/// GET /api/automation/logs +pub async fn get_automation_logs_handler( + Query(query): Query, + State(database): State>, +) -> ( + StatusCode, + Json>, +) { + match database.get_automation_logs( + &query.task_type, + &query.status, + &query.q, + &query.start_date, + &query.end_date, + query.limit, + query.offset, + ) { + Ok(logs) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Success", logs)), + ), + Err(err) => ( + StatusCode::OK, + Json(ApiResponse::error(format!("Failed: {}", err))), + ), + } +} + +/// POST /api/automation/logs/clear +pub async fn clear_automation_logs_handler( + State(database): State>, + payload: Option>, +) -> (StatusCode, Json>) { + let filters = payload.map(|Json(value)| value).unwrap_or_default(); + match database.clear_automation_logs( + &filters.task_type, + &filters.status, + &filters.start_date, + &filters.end_date, + ) { + Ok(deleted) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Automation logs cleared", + json!({ "deleted": deleted }), + )), + ), + Err(err) => ( + StatusCode::OK, + Json(ApiResponse::error(format!("Failed: {}", err))), + ), + } +} + +/// POST /api/automation/test/{task_id} +pub async fn test_automation_task_handler( + Path(task_id): Path, + State(app_state): State, +) -> (StatusCode, Json>) { + let config = app_state.config_manager.get_automation_config(); + let task = config.tasks.iter().find(|t| t.id == task_id).cloned(); + + let Some(task) = task else { + return (StatusCode::OK, Json(ApiResponse::error("自动化任务不存在"))); + }; + + tokio::spawn(async move { + let registry = crate::automation::tasks::TaskRegistry::new(); + let task_type = match &task.action { + crate::config::AutomationAction::RestartBaseband => "restart_baseband", + crate::config::AutomationAction::RebootDevice { .. } => "reboot_device", + crate::config::AutomationAction::SendSms { .. } => "send_sms", + }; + + let handler = match registry.get(task_type) { + Some(h) => h, + None => { + let err_msg = format!("未找到该任务类型的处理器: {}", task_type); + let _ = app_state + .database + .insert_automation_log(&task.id, &task.name, task_type, "failed", &err_msg); + return; + } + }; + + let mut delay_secs = 0u64; + let params = match &task.action { + crate::config::AutomationAction::RestartBaseband => serde_json::Value::Null, + crate::config::AutomationAction::RebootDevice { delay_seconds } => { + serde_json::json!({ "delay_seconds": delay_seconds }) + } + crate::config::AutomationAction::SendSms { + phone_number, + content, + random_delay_seconds, + retry_limit, + } => { + delay_secs = u64::from(random_delay_seconds.unwrap_or(0)); + serde_json::json!({ + "phone_number": phone_number, + "content": content, + "random_delay_seconds": random_delay_seconds, + "retry_limit": retry_limit + }) + } + }; + + let result = tokio::time::timeout( + tokio::time::Duration::from_secs(60 + delay_secs), + handler.execute(&app_state, ¶ms), + ) + .await; + + let (status, detail) = match result { + Ok(Ok(_)) => ("success", "执行成功".to_string()), + Ok(Err(e)) => ("failed", format!("执行失败: {}", e)), + Err(_) => ("failed", "执行超时 (超过60秒限制)".to_string()), + }; + + let _ = app_state + .database + .insert_automation_log(&task.id, &task.name, task_type, status, &detail); + + let event = crate::notification::AutomationEvent { + task_id: task.id.clone(), + task_name: task.name.clone(), + task_type: task_type.to_string(), + status: status.to_string(), + message: detail.clone(), + timestamp: crate::db::beijing_sms_now_string(), + }; + + let _ = app_state + .notification_sender + .forward_automation_event(&event) + .await; + }); + + ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "任务已在后台下发立即执行", + json!({}), + )), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::modem_manager::SimIdentity; + + #[test] + fn enriches_enabled_esim_profile_from_current_sim_identity() { + let mut profiles = vec![ + EsimProfile { + iccid: "profile-a".to_string(), + state: "disabled".to_string(), + ..Default::default() + }, + EsimProfile { + iccid: "profile-b".to_string(), + state: "disabled".to_string(), + ..Default::default() + }, + ]; + let identity = SimIdentity { + iccid: "profile-b".to_string(), + imsi: "234336".to_string(), + operator_id: "234336".to_string(), + }; + + enrich_profiles_with_current_identity(&mut profiles, &identity); + + assert_eq!(profiles[1].state, "enabled"); + assert_eq!(profiles[1].imsi.as_deref(), Some("234336")); + assert_eq!(profiles[1].mcc.as_deref(), Some("234")); + assert_eq!(profiles[1].mnc.as_deref(), Some("336")); + assert!(profiles[0].mcc.is_none()); + } + + #[test] + fn enriches_unknown_cached_profile_states_from_current_sim_identity() { + let mut profiles = vec![ + EsimProfile { + iccid: "profile-a".to_string(), + state: "unknown".to_string(), + ..Default::default() + }, + EsimProfile { + iccid: "profile-b".to_string(), + state: "unknown".to_string(), + ..Default::default() + }, + ]; + let identity = SimIdentity { + iccid: "profile-b".to_string(), + imsi: String::new(), + operator_id: String::new(), + }; + + enrich_profiles_with_current_identity(&mut profiles, &identity); + + assert_eq!(profiles[0].state, "disabled"); + assert_eq!(profiles[1].state, "enabled"); + } + + #[test] + fn sorts_esim_profiles_with_enabled_first_and_stable_fallback() { + let mut profiles = vec![ + EsimProfile { + iccid: "300".to_string(), + name: "Charlie".to_string(), + state: "disabled".to_string(), + ..Default::default() + }, + EsimProfile { + iccid: "100".to_string(), + name: "Alpha".to_string(), + state: "disabled".to_string(), + ..Default::default() + }, + EsimProfile { + iccid: "200".to_string(), + name: "Bravo".to_string(), + state: "enabled".to_string(), + ..Default::default() + }, + ]; + + sort_esim_profiles_for_display(&mut profiles); + + let order: Vec<&str> = profiles.iter().map(|profile| profile.iccid.as_str()).collect(); + assert_eq!(order, vec!["200", "100", "300"]); + } + + #[test] + fn splits_five_digit_operator_codes_for_profile_enrichment() { + assert_eq!( + split_profile_operator_code("46002"), + ("460".to_string(), "02".to_string()) + ); + } + + #[test] + fn labels_temperature_sensors_with_dashboard_names() { + assert_eq!(temperature_sensor_label("modem-thermal", ""), "基带"); + assert_eq!(temperature_sensor_label("cpu0-1-thermal", ""), "CPU 0-1"); + assert_eq!(temperature_sensor_label("core2_3_temp", ""), "核心 2-3"); + assert_eq!(temperature_sensor_label("wifi_sensor", ""), "Wi-Fi"); + } +} diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/backend/src/main.rs b/packages/operation-registry/test/fixtures/upstream-58e2204/backend/src/main.rs new file mode 100644 index 0000000..88add25 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/backend/src/main.rs @@ -0,0 +1,1042 @@ +//! SimAdmin - Debian SIM Management Service +//! +//! A backend service for managing Debian-based modem and SIM devices. +//! Built with Rust + Axum + zbus. +//! + +use anyhow::Result; +use axum::{ + extract::DefaultBodyLimit, + http::{StatusCode, Uri}, + middleware, + response::{IntoResponse, Response}, + routing::{delete, get, post}, + Router, +}; +use clap::{Args as ClapArgs, Parser, Subcommand}; +use socket2::{Domain, Protocol, Socket, Type}; +use std::net::{IpAddr, Ipv6Addr, SocketAddr}; +use std::path::PathBuf; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; +use tower_http::cors::{Any, CorsLayer}; +use tracing::{info, warn}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; +use zbus::Connection; + +mod auth; +mod automation; +mod cell_lock_store; +mod config; +mod db; +mod device_network; +mod device_status; +mod esim; +mod handlers; +mod iptables; +mod models; +mod modem_manager; +mod notification; +mod notification_queue; +mod ota; +mod serial; +mod sms_listener; +mod state; +mod system_event; +mod system_event_monitor; +mod utils; +mod verification_code; + +use config::{get_default_config_path, ConfigManager}; +use db::Database; +use device_network::DdnsManager; +use esim::EsimSupervisor; +use handlers::*; +use modem_manager::{ensure_nm_modem_profile, init_data_connection}; +use notification::NotificationSender; +use notification_queue::*; +use state::AppState; +use system_event::{ + codes as system_event_codes, severity as system_event_severity, status as system_event_status, + SystemEventEmitter, +}; + +/// 获取二进制文件同级目录下的 www 目录路径 +fn get_www_dir() -> PathBuf { + // 获取当前可执行文件的路径 + let exe_path = std::env::current_exe().expect("Failed to get executable path"); + + // 获取可执行文件所在目录 + let exe_dir = exe_path + .parent() + .expect("Failed to get executable directory"); + + // 拼接 www 目录 + exe_dir.join("www") +} + +fn get_data_db_path() -> PathBuf { + std::env::current_exe() + .expect("Failed to get executable path") + .parent() + .expect("Failed to get executable directory") + .join("data.db") +} + +/// SPA fallback handler - 对于所有前端路由返回 index.html +async fn spa_fallback(uri: Uri) -> Response { + let path = uri.path(); + + // 如果是 API 路由,返回 404(不应该走到这里,但作为保险) + if path.starts_with("/api/") { + return (StatusCode::NOT_FOUND, "API endpoint not found").into_response(); + } + + // 获取 www 目录的绝对路径 + let www_dir = get_www_dir(); + + // 构建请求文件的完整路径 + let requested_path = if path == "/" { "/index.html" } else { path }; + let file_path = www_dir.join(requested_path.trim_start_matches('/')); + + // 如果文件存在,返回文件内容 + if let Ok(content) = tokio::fs::read(&file_path).await { + // 根据文件扩展名设置正确的 Content-Type + let content_type = match file_path.extension().and_then(|ext| ext.to_str()) { + Some("html") => "text/html; charset=utf-8", + Some("css") => "text/css; charset=utf-8", + Some("js") => "application/javascript; charset=utf-8", + Some("json") => "application/json", + Some("png") => "image/png", + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("gif") => "image/gif", + Some("svg") => "image/svg+xml", + Some("ico") => "image/x-icon", + _ => "application/octet-stream", + }; + + return ( + StatusCode::OK, + [(axum::http::header::CONTENT_TYPE, content_type)], + content, + ) + .into_response(); + } + + // 如果文件不存在,返回 index.html(SPA 路由) + let index_path = www_dir.join("index.html"); + match tokio::fs::read(&index_path).await { + Ok(content) => ( + StatusCode::OK, + [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")], + content, + ) + .into_response(), + Err(_) => ( + StatusCode::NOT_FOUND, + format!( + "index.html not found at {:?}. Please build the frontend first.", + index_path + ), + ) + .into_response(), + } +} + +/// 确保 ModemManager 开启 debug 提权模式,但为了防止日志爆炸, +/// 启动后立刻通过 ExecStartPost 将日志级别降回 INFO。 +/// 这完美绕过了 Modem.Command 的 Unauthorized 限制,同时保持系统纯净。 +fn ensure_modemmanager_debug_override() { + let override_dir = "/etc/systemd/system/ModemManager.service.d"; + let override_file = "/etc/systemd/system/ModemManager.service.d/99-simadmin-debug.conf"; + + let desired_content = "\ +# SimAdmin: enable ModemManager debug mode so that Modem.Command D-Bus +# interface is available for AT+CRSM (SIM file read) and AT+CSCA? (SMSC query). +# We immediately set logging level back to INFO via ExecStartPost to prevent log spam. +[Service] +ExecStart= +ExecStart=/usr/sbin/ModemManager --debug +ExecStartPost=-/usr/bin/busctl call org.freedesktop.ModemManager1 /org/freedesktop/ModemManager1 org.freedesktop.ModemManager1 SetLogging s \"INFO\" +"; + + let needs_update = match std::fs::read_to_string(override_file) { + Ok(content) => content != desired_content, + Err(_) => true, + }; + + if needs_update { + tracing::info!("Applying ModemManager debug override (with silent logging)..."); + let _ = std::fs::create_dir_all(override_dir); + if let Err(e) = std::fs::write(override_file, desired_content) { + tracing::warn!("Failed to write MM debug override: {}", e); + return; + } + + // Reload systemd & restart ModemManager silently + let _ = std::process::Command::new("systemctl") + .arg("daemon-reload") + .output(); + let _ = std::process::Command::new("systemctl") + .args(["restart", "ModemManager.service"]) + .output(); + tracing::info!("ModemManager debug override applied and service restarted."); + } +} + +/// 解压 ZIP 文件到指定目录(供安装脚本使用,无需依赖 unzip / python3) +fn run_extract_zip(archive: &str, target: &str) -> Result<()> { + use std::io; + + let file = std::fs::File::open(archive) + .map_err(|e| anyhow::anyhow!("Failed to open {}: {}", archive, e))?; + let mut zip = zip::ZipArchive::new(file) + .map_err(|e| anyhow::anyhow!("Invalid zip archive {}: {}", archive, e))?; + + let target_dir = std::path::Path::new(target); + std::fs::create_dir_all(target_dir) + .map_err(|e| anyhow::anyhow!("Failed to create target dir {}: {}", target, e))?; + + for i in 0..zip.len() { + let mut entry = zip.by_index(i)?; + let Some(path) = entry.enclosed_name().map(|p| target_dir.join(p)) else { + continue; + }; + + if entry.is_dir() { + std::fs::create_dir_all(&path)?; + continue; + } + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut outfile = std::fs::File::create(&path)?; + io::copy(&mut entry, &mut outfile)?; + + // 保留可执行权限 + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Some(mode) = entry.unix_mode() { + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode))?; + } + } + } + + println!("Extracted {} entries to {}", zip.len(), target); + Ok(()) +} + +#[derive(Parser, Debug)] +#[command(name = "simadmin")] +#[command(author, version, about, long_about = None)] +struct Cli { + #[command(subcommand)] + command: Option, + + #[command(flatten)] + serve: ServeArgs, +} + +#[derive(Subcommand, Debug)] +enum CliCommand { + /// 启动 Web 管理服务 + Serve(ServeArgs), + /// 管理 Web 登录认证(用于 SSH 本机恢复) + Auth { + #[command(subcommand)] + command: AuthCommand, + }, + /// 解压 ZIP 文件到指定目录(供安装脚本调用) + ExtractZip { + /// ZIP 文件路径 + archive: String, + /// 解压目标目录 + target: String, + }, +} + +#[derive(Subcommand, Debug)] +enum AuthCommand { + /// 交互式重置管理员密码,并清空所有 Web 会话 + ResetPassword, + /// 清除管理员密码,让 Web UI 下次进入首次设置 + Clear, +} + +#[derive(ClapArgs, Debug, Clone)] +struct ServeArgs { + /// 监听端口 (默认: 3000) + #[arg(short, long, default_value = "3000", env = "PORT")] + port: u16, + + /// 监听地址 (默认: ::,双栈监听 IPv4/IPv6) + #[arg(short = 'H', long, default_value = "::", env = "HOST")] + host: String, +} + +#[tokio::main] +async fn main() -> Result<()> { + // 初始化 tracing 日志框架 + // 通过 RUST_LOG 环境变量控制日志级别,默认为 info + tracing_subscriber::registry() + .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))) + .with(tracing_subscriber::fmt::layer().with_target(false)) + .init(); + + // 解析命令行参数 + let cli = Cli::parse(); + + // 处理非服务子命令 + if let Some(CliCommand::ExtractZip { archive, target }) = &cli.command { + return run_extract_zip(archive, target); + } + if let Some(CliCommand::Auth { command }) = &cli.command { + let db = Database::new(get_data_db_path())?; + let config_manager = ConfigManager::new(get_default_config_path()); + let security = config_manager.get_security(); + return match command { + AuthCommand::ResetPassword => auth::reset_admin_password_interactive(&db, &security), + AuthCommand::Clear => auth::clear_admin_auth(&db), + }; + } + + let args = match cli.command { + Some(CliCommand::Serve(args)) => args, + None => cli.serve, + _ => unreachable!(), + }; + let bind_addr = display_bind_addr(&args.host, args.port); + + // 确保 ModemManager 已提权以支持 AT 指令读取短信中心 + ensure_modemmanager_debug_override(); + + // Connect to system D-Bus + let dbus_conn = Arc::new(Connection::system().await?); + + // 创建 SMS 数据库(存储在可执行文件同级目录) + let db_path = get_data_db_path(); + let app_db = Arc::new(Database::new(db_path)?); + + // 初始化配置管理器 + let config_path = get_default_config_path(); + info!(path = ?config_path, "Loading config"); + let config_manager = Arc::new(ConfigManager::new(config_path)); + let data_user_disabled = Arc::new(AtomicBool::new(!config_manager.get_data_enabled())); + let airplane_mode_requested = Arc::new(AtomicBool::new(false)); + let cell_monitoring_active = Arc::new(AtomicBool::new(false)); + let esim_supervisor = Arc::new(EsimSupervisor::new(Arc::clone(&config_manager))); + + let nm_result = ensure_nm_modem_profile().await; + tracing::info!(result = %nm_result, "NetworkManager modem profile setup completed"); + + // 初始化通知发送器 + let notification_sender = Arc::new(NotificationSender::new( + Arc::clone(&config_manager), + Arc::clone(&dbus_conn), + Arc::clone(&app_db), + )); + let system_event_emitter = Arc::new(SystemEventEmitter::new(Arc::clone(¬ification_sender))); + let (sms_resync, sms_resync_rx) = sms_listener::sms_resync_channel(); + let ddns_manager = Arc::new(DdnsManager::new()); + { + let notification_queue_worker = Arc::clone(¬ification_sender); + tokio::spawn(async move { + notification_queue_worker.run_queue_worker().await; + }); + } + system_event_monitor::spawn_system_event_monitor( + Arc::clone(&system_event_emitter), + Arc::clone(&dbus_conn), + ); + device_status::spawn_device_status_scheduler( + Arc::clone(&config_manager), + Arc::clone(¬ification_sender), + Arc::clone(&app_db), + Arc::clone(&dbus_conn), + Arc::clone(&ddns_manager), + ); + + { + let ddns_manager_clone = Arc::clone(&ddns_manager); + let config_clone = Arc::clone(&config_manager); + let notification_clone = Arc::clone(¬ification_sender); + tokio::spawn(async move { + tokio::time::sleep(tokio::time::Duration::from_secs(10)).await; + loop { + let config = config_clone.get_ddns_config(); + let interval = config.interval_seconds.max(60); + if config.enabled { + if let Err(err) = ddns_manager_clone + .sync_now(Arc::clone(&config_clone), Arc::clone(¬ification_clone)) + .await + { + tracing::warn!(error = %err, "DDNS background sync failed"); + } + } + tokio::time::sleep(tokio::time::Duration::from_secs(interval)).await; + } + }); + } + + { + let config_clone = Arc::clone(&config_manager); + let notification_clone = Arc::clone(¬ification_sender); + tokio::spawn(async move { + loop { + tokio::time::sleep(crate::ota::duration_until_next_update_check()).await; + let config = config_clone.get_version_update_notifications(); + if config.enabled { + if let Err(err) = crate::ota::check_and_notify_version_update( + Arc::clone(&config_clone), + Arc::clone(¬ification_clone), + ) + .await + { + tracing::warn!(error = %err, "Version update notification check failed"); + } + } + } + }); + } + + // 启动 SMS 监听线程 + { + let conn_clone = Connection::system().await?; + let db_clone = Arc::clone(&app_db); + let notification_clone = Arc::clone(¬ification_sender); + let resync_rx = sms_resync_rx; + tokio::spawn(async move { + let _ = sms_listener::start_sms_listener( + conn_clone, + db_clone, + notification_clone, + resync_rx, + ) + .await; + }); + } + + // 电话监听暂不启用 + + // 自动初始化数据连接 + { + let conn_clone = Arc::clone(&dbus_conn); + let user_off = Arc::clone(&data_user_disabled); + let cfg = Arc::clone(&config_manager); + tokio::spawn(async move { + // 等待 2 秒让 modem 完全初始化 + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + let allow_roaming = cfg.get_roaming_allowed(); + let apn_config = cfg.get_apn_config(); + let result = init_data_connection( + conn_clone.as_ref(), + user_off.as_ref(), + allow_roaming, + Some(apn_config), + ) + .await; + tracing::info!(result = %result, "Auto-connect completed"); + }); + } + + // 启动数据连接 Watchdog(每 15 秒检查一次) + { + let conn_clone = Arc::clone(&dbus_conn); + let user_off = Arc::clone(&data_user_disabled); + let airplane_requested = Arc::clone(&airplane_mode_requested); + let cfg = Arc::clone(&config_manager); + let system_events = Arc::clone(&system_event_emitter); + tokio::spawn(async move { + // 初始延迟 5 秒,等待系统稳定 + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + tracing::info!(interval = 15, "Watchdog started"); + modem_manager::data_connection_watchdog( + conn_clone, + 15, + user_off, + airplane_requested, + cfg, + system_events, + ) + .await; + }); + } + + system_event_emitter + .emit_code( + system_event_codes::SYSTEM_SERVICE_STARTED, + system_event_severity::INFO, + system_event_status::SUCCEEDED, + "simadmin", + "SimAdmin 服务启动完成", + ) + .await; + + // CORS 配置:允许前端开发服务器跨域访问 + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any); + + // 创建统一的应用状态 + spawn_system_stats_sampler(Arc::clone(&dbus_conn)); + + let app_state = AppState::new( + dbus_conn, + app_db, + config_manager, + notification_sender, + system_event_emitter, + ddns_manager, + Arc::clone(&esim_supervisor), + sms_resync, + data_user_disabled, + airplane_mode_requested, + cell_monitoring_active, + ); + + // 启动自动化中心后台调度引擎 + automation::spawn_automation_scheduler(app_state.clone()); + + // Build protected routes - 使用统一的 AppState + let protected_routes = Router::new() + // ========== 设备信息接口 ========== + .route("/api/device", get(get_device_info).options(options_handler)) + // ========== SIM 卡接口 ========== + .route("/api/sim", get(get_sim_info).options(options_handler)) + .route( + "/api/sim/details/refresh", + post(refresh_sim_details_handler).options(options_handler), + ) + .route( + "/api/sim/cache", + post(update_sim_cache_handler).options(options_handler), + ) + // ========== 网络接口 ========== + .route( + "/api/network", + get(get_network_info).options(options_handler), + ) + .route("/api/cells", get(get_cells).options(options_handler)) + .route( + "/api/cell-monitor/start", + post(start_cell_monitor_handler).options(options_handler), + ) + .route( + "/api/cell-monitor/stop", + post(stop_cell_monitor_handler).options(options_handler), + ) + .route( + "/api/radio-mode", + get(get_radio_mode_handler) + .post(set_radio_mode_handler) + .options(options_handler), + ) + .route( + "/api/band-lock", + get(get_band_lock_handler) + .post(set_band_lock_handler) + .options(options_handler), + ) + .route( + "/api/network/interfaces", + get(get_network_interfaces_info).options(options_handler), + ) + .route( + "/api/network/connection-addresses", + get(get_network_connection_addresses).options(options_handler), + ) + .route( + "/api/device-network/ddns/config", + get(get_device_ddns_config_handler) + .post(set_device_ddns_config_handler) + .options(options_handler), + ) + .route( + "/api/device-network/ddns/status", + get(get_device_ddns_status_handler).options(options_handler), + ) + .route( + "/api/device-network/ddns/sync", + post(sync_device_ddns_handler).options(options_handler), + ) + .route( + "/api/device-network/ddns/logs", + get(get_device_ddns_logs_handler).options(options_handler), + ) + .route( + "/api/device-network/ddns/logs/clear", + post(clear_device_ddns_logs_handler).options(options_handler), + ) + .route( + "/api/device-network/wlan/status", + get(get_device_wlan_status_handler).options(options_handler), + ) + .route( + "/api/device-network/wlan/enabled", + post(set_device_wlan_enabled_handler).options(options_handler), + ) + .route( + "/api/device-network/wlan/scan", + post(scan_device_wlan_handler).options(options_handler), + ) + .route( + "/api/device-network/wlan/profiles", + get(get_device_wlan_profiles_handler).options(options_handler), + ) + .route( + "/api/device-network/wlan/forget", + post(forget_device_wlan_handler).options(options_handler), + ) + .route( + "/api/device-network/wlan/connect", + post(connect_device_wlan_handler).options(options_handler), + ) + .route( + "/api/device-network/wlan/disconnect", + post(disconnect_device_wlan_handler).options(options_handler), + ) + .route( + "/api/device-network/wlan/profile", + post(save_device_wlan_profile_handler).options(options_handler), + ) + .route( + "/api/network/signal-strength", + get(get_signal_strength_handler).options(options_handler), + ) + .route( + "/api/location/cell-info", + get(get_cell_location_handler).options(options_handler), + ) + .route( + "/api/network/operators", + get(get_network_operators).options(options_handler), + ) + .route( + "/api/network/operators/scan", + get(scan_network_operators).options(options_handler), + ) + .route( + "/api/network/register-manual", + post(register_network_manual).options(options_handler), + ) + .route( + "/api/network/register-auto", + post(register_network_auto).options(options_handler), + ) + .route( + "/api/apn", + get(get_apn_list_handler) + .post(set_apn_handler) + .options(options_handler), + ) + .route( + "/api/cell-lock", + get(get_cell_lock_status_handler) + .post(set_cell_lock_handler) + .options(options_handler), + ) + .route( + "/api/cell-lock/unlock-all", + post(unlock_all_cells_handler).options(options_handler), + ) + // ========== 数据连接接口 ========== + .route( + "/api/data", + get(get_data_status) + .post(set_data_status) + .options(options_handler), + ) + .route( + "/api/roaming", + get(get_roaming_status_handler) + .post(set_roaming_status_handler) + .options(options_handler), + ) + .route( + "/api/airplane-mode", + get(get_airplane_mode_handler) + .post(set_airplane_mode_handler) + .options(options_handler), + ) + .route( + "/api/baseband/restart", + post(restart_baseband_handler).options(options_handler), + ) + .route( + "/api/baseband/restart/status", + get(get_baseband_restart_status_handler).options(options_handler), + ) + // ========== 工作模式 / eSIM 管理 ========== + .route( + "/api/work-mode", + get(get_work_mode_handler) + .post(set_work_mode_handler) + .options(options_handler), + ) + .route( + "/api/esim/config", + get(get_esim_config_handler) + .post(set_esim_config_handler) + .options(options_handler), + ) + .route( + "/api/esim/lpac/status", + get(get_esim_lpac_status_handler).options(options_handler), + ) + .route( + "/api/esim/lpac/repair", + post(repair_esim_lpac_handler).options(options_handler), + ) + .route( + "/api/esim/euicc", + get(get_esim_euicc_handler).options(options_handler), + ) + .route( + "/api/esim/profiles", + get(get_esim_profiles_handler) + .post(download_esim_profile_handler) + .options(options_handler), + ) + .route( + "/api/esim/profiles/{iccid}/enable", + post(enable_esim_profile_handler).options(options_handler), + ) + .route( + "/api/esim/profiles/{iccid}/rename", + post(rename_esim_profile_handler).options(options_handler), + ) + .route( + "/api/esim/profiles/{iccid}", + delete(delete_esim_profile_handler).options(options_handler), + ) + // ========== 电话功能接口 ========== + .route( + "/api/calls", + get(get_calls_handler).options(options_handler), + ) + .route( + "/api/call/dial", + post(dial_call_handler).options(options_handler), + ) + .route( + "/api/call/hangup", + post(hangup_call_handler).options(options_handler), + ) + .route( + "/api/call/hangup-all", + post(hangup_all_calls_handler).options(options_handler), + ) + .route( + "/api/call/answer", + post(answer_call_handler).options(options_handler), + ) + .route( + "/api/call/volume", + get(get_call_volume_handler) + .post(set_call_volume_handler) + .options(options_handler), + ) + .route( + "/api/call/forwarding", + get(get_call_forwarding_handler) + .post(set_call_forwarding_handler) + .options(options_handler), + ) + .route( + "/api/call/settings", + get(get_call_settings_handler) + .post(set_call_settings_handler) + .options(options_handler), + ) + .route( + "/api/call/history", + get(get_call_history_handler).options(options_handler), + ) + .route( + "/api/call/history/{id}", + axum::routing::delete(delete_call_history_handler).options(options_handler), + ) + .route( + "/api/call/history/clear", + post(clear_call_history_handler).options(options_handler), + ) + .route( + "/api/ims/status", + get(get_ims_status_handler).options(options_handler), + ) + .route( + "/api/voicemail/status", + get(get_voicemail_status_handler).options(options_handler), + ) + // ========== 短信功能接口 ========== + .route( + "/api/sms/send", + post(send_sms_handler).options(options_handler), + ) + .route( + "/api/sms/list", + get(get_sms_list_handler).options(options_handler), + ) + .route( + "/api/sms/conversation", + get(get_sms_conversation_handler).options(options_handler), + ) + .route( + "/api/sms/stats", + get(get_sms_stats_handler).options(options_handler), + ) + .route( + "/api/sms/batch-delete", + post(delete_sms_batch_handler).options(options_handler), + ) + .route( + "/api/sms/conversation/{phone_number}", + axum::routing::delete(delete_sms_conversation_handler).options(options_handler), + ) + .route( + "/api/sms/message/{id}", + axum::routing::delete(delete_sms_message_handler).options(options_handler), + ) + .route( + "/api/sms/clear", + post(clear_sms_handler).options(options_handler), + ) + // ========== 系统接口 ========== + .route("/api/stats", get(get_system_stats).options(options_handler)) + .route("/api/stats/cpu", get(get_cpu_info).options(options_handler)) + .route( + "/api/connectivity", + get(get_connectivity_check).options(options_handler), + ) + .route( + "/api/system/reboot", + post(system_reboot).options(options_handler), + ) + .route( + "/api/service/restart", + post(restart_service_handler).options(options_handler), + ) + // ========== 通知配置接口 ========== + .route( + "/api/notifications/config", + get(get_notification_config_handler) + .post(set_notification_config_handler) + .options(options_handler), + ) + .route( + "/api/notifications/test/{channel}", + post(test_notification_channel_handler).options(options_handler), + ) + // ========== OTA 更新接口 ========== + .route( + "/api/notifications/logs", + get(get_notification_logs_handler).options(options_handler), + ) + .route( + "/api/notifications/logs/clear", + post(clear_notification_logs_handler).options(options_handler), + ) + .route( + "/api/notifications/queue", + get(get_notification_queue_handler).options(options_handler), + ) + .route( + "/api/notifications/queue/retry-all", + post(retry_all_notification_queue_handler).options(options_handler), + ) + .route( + "/api/notifications/queue/clear", + post(clear_notification_queue_handler).options(options_handler), + ) + .route( + "/api/notifications/queue/{id}", + delete(delete_notification_queue_item_handler).options(options_handler), + ) + .route( + "/api/notifications/queue/{id}/retry", + post(retry_notification_queue_item_handler).options(options_handler), + ) + // ========== 自动化中心接口 ========== + .route( + "/api/automation/config", + get(get_automation_config_handler) + .post(set_automation_config_handler) + .options(options_handler), + ) + .route( + "/api/automation/logs", + get(get_automation_logs_handler).options(options_handler), + ) + .route( + "/api/automation/logs/clear", + post(clear_automation_logs_handler).options(options_handler), + ) + .route( + "/api/automation/test/{task_id}", + post(test_automation_task_handler).options(options_handler), + ) + .route( + "/api/ota/status", + get(get_ota_status_handler).options(options_handler), + ) + .route( + "/api/ota/upload", + post(upload_ota_handler) + .options(options_handler) + .layer(DefaultBodyLimit::max(50 * 1024 * 1024)), + ) + .route( + "/api/ota/latest-release", + post(get_latest_ota_release_handler).options(options_handler), + ) + .route( + "/api/ota/online-prepare", + post(prepare_online_ota_handler).options(options_handler), + ) + .route( + "/api/ota/apply", + post(apply_ota_handler).options(options_handler), + ) + .route( + "/api/ota/cancel", + post(cancel_ota_handler).options(options_handler), + ) + .route( + "/api/auth/password", + post(auth::change_password).options(options_handler), + ) + .route( + "/api/auth/settings", + get(auth::get_settings) + .post(auth::set_settings) + .options(options_handler), + ) + .route_layer(middleware::from_fn_with_state( + app_state.clone(), + auth::auth_middleware, + )); + + let app = Router::new() + .route("/api/health", get(health_check).options(options_handler)) + .route( + "/api/auth/status", + get(auth::status).options(options_handler), + ) + .route( + "/api/auth/setup", + post(auth::setup).options(options_handler), + ) + .route( + "/api/auth/login", + post(auth::login).options(options_handler), + ) + .route( + "/api/auth/logout", + post(auth::logout).options(options_handler), + ) + .merge(protected_routes) + .with_state(app_state) + .layer(cors) + .fallback(spa_fallback); + + // Start server - 显示版权信息 + info!( + version = env!("APP_VERSION"), + branch = env!("GIT_BRANCH"), + commit = env!("GIT_COMMIT"), + "SimAdmin - Debian SIM Management Service" + ); + info!("Copyright © 2026 GitHub 3899 - SimAdmin"); + + // 绑定端口,如果被占用则轮询等待(最多 30 秒) + let listener = bind_with_retry(&args.host, args.port, 30).await?; + info!(addr = %bind_addr, "Server listening"); + // 使用优雅关闭 + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await?; + + Ok(()) +} + +/// 绑定端口,如果被占用则轮询等待 +fn display_bind_addr(host: &str, port: u16) -> String { + let host = host.trim_matches(|c| c == '[' || c == ']'); + if host.contains(':') { + format!("[{host}]:{port}") + } else { + format!("{host}:{port}") + } +} + +async fn bind_listener(host: &str, port: u16) -> std::io::Result { + let normalized_host = host.trim_matches(|c| c == '[' || c == ']'); + if normalized_host == "::" { + let socket = Socket::new(Domain::IPV6, Type::STREAM, Some(Protocol::TCP))?; + socket.set_reuse_address(true)?; + socket.set_only_v6(false)?; + socket.bind(&SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), port).into())?; + socket.listen(1024)?; + socket.set_nonblocking(true)?; + let listener: std::net::TcpListener = socket.into(); + return tokio::net::TcpListener::from_std(listener); + } + + tokio::net::TcpListener::bind((normalized_host, port)).await +} + +async fn bind_with_retry( + host: &str, + port: u16, + max_retries: u32, +) -> Result { + use std::time::Duration; + let addr = display_bind_addr(host, port); + + for i in 0..max_retries { + match bind_listener(host, port).await { + Ok(listener) => return Ok(listener), + Err(e) => { + if i == 0 { + warn!(addr = %addr, "Port busy, waiting for release..."); + } + if i + 1 < max_retries { + tokio::time::sleep(Duration::from_secs(1)).await; + } else { + return Err(anyhow::anyhow!("Failed to bind to {}: {}", addr, e)); + } + } + } + } + unreachable!() +} + +/// 监听 Ctrl+C 和 SIGTERM 信号,用于优雅关闭 +async fn shutdown_signal() { + use tokio::signal; + + let ctrl_c = async { + signal::ctrl_c() + .await + .expect("Failed to install Ctrl+C signal handler"); + }; + + #[cfg(unix)] + let terminate = async { + signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("Failed to install SIGTERM signal handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } +} diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/backend/src/models.rs b/packages/operation-registry/test/fixtures/upstream-58e2204/backend/src/models.rs new file mode 100644 index 0000000..93d2fb7 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/backend/src/models.rs @@ -0,0 +1,1039 @@ +//! Shared request and response models for the active SimAdmin backend. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::db::{CallRecord, CallStats, SmsMessage, SmsStats}; + +#[derive(Debug, Serialize)] +pub struct ApiResponse { + pub status: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +impl ApiResponse { + pub fn success_with_message(message: impl Into, data: T) -> Self { + Self { + status: "ok".to_string(), + message: message.into(), + data: Some(data), + } + } +} + +impl ApiResponse +where + T: Default, +{ + pub fn error(message: impl Into) -> Self { + Self { + status: "error".to_string(), + message: message.into(), + data: None, + } + } +} + +#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum WorkMode { + #[default] + Sim, + Esim, +} + +impl std::fmt::Display for WorkMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + WorkMode::Sim => write!(f, "sim"), + WorkMode::Esim => write!(f, "esim"), + } + } +} + +#[derive(Debug, Default, Deserialize)] +pub struct WorkModeRequest { + pub mode: WorkMode, + #[serde(default)] + pub confirm: bool, +} + +#[derive(Debug, Default, Serialize)] +pub struct WorkModeResponse { + pub mode: WorkMode, + pub worker_running: bool, +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct EsimCommandResponse { + #[serde(default)] + pub code: i32, + #[serde(default)] + pub status: String, + #[serde(default)] + pub action: String, + #[serde(default)] + pub msg: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +#[derive(Debug, Default, Serialize, Clone)] +pub struct EsimEuiccInfo { + pub eid: String, + pub status: String, + pub manufacturer: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub memory_total_kb: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub memory_available_kb: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub memory_total_customizable: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_at: Option, + #[serde(default)] + pub raw: Value, +} + +#[derive(Debug, Default, Serialize, Clone)] +pub struct EsimProfile { + pub iccid: String, + pub name: String, + pub provider: String, + pub state: String, + #[serde(rename = "class")] + pub profile_class: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub imsi: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub msisdn: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub smsc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub smdp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub matching_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub isdp_aid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mcc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mnc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub disable_allowed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub delete_allowed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_at: Option, + #[serde(default)] + pub raw: Value, +} + +#[derive(Debug, Default, Serialize)] +pub struct EsimProfilesResponse { + pub profiles: Vec, +} + +#[derive(Debug, Default, Serialize)] +pub struct EsimLpacStatusResponse { + pub installed: bool, + pub usable: bool, + pub path: String, + pub arch: String, + pub glibc_version: String, + pub asset_name: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option, +} + +#[derive(Debug, Default, Deserialize)] +pub struct EsimLpacRepairRequest { + pub proxy_prefix: Option, + pub asset_url: Option, +} + +#[derive(Debug, Default, Serialize)] +pub struct EsimLpacRepairResponse { + pub installed: bool, + pub path: String, + pub arch: String, + pub asset_name: String, + pub asset_url: String, + pub message: String, +} + +#[derive(Debug, Deserialize)] +pub struct EsimRenameRequest { + pub name: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct EsimDownloadRequest { + pub smdp: String, + pub matching_id: String, + pub confirmation_code: Option, + pub imei: Option, +} + +#[derive(Debug, Default, Serialize, Clone)] +pub struct ServingCell { + pub tech: String, + pub cell_id: u32, + pub tac: u32, +} + +#[derive(Debug, Default, Serialize, Clone)] +pub struct CellInfo { + pub is_serving: bool, + pub tech: String, + #[serde(default)] + pub cell_id: u32, + pub band: String, + pub arfcn: String, + pub pci: String, + pub rsrp: String, + pub rsrq: String, + pub sinr: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub earfcn: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub nrarfcn: String, + #[serde(skip_serializing_if = "String::is_empty")] + #[serde(rename = "type")] + pub cell_type: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub ssb_rsrp: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub ssb_rsrq: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub ssb_sinr: String, +} + +#[derive(Debug, Default, Serialize)] +pub struct CellsResponse { + #[serde(default)] + pub serving_cell: ServingCell, + pub cells: Vec, +} + +#[derive(Debug, Default, Serialize)] +pub struct DeviceInfoResponse { + pub imei: String, + pub manufacturer: String, + pub model: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub revision: Option, + pub online: bool, + pub powered: bool, +} + +#[derive(Debug, Deserialize)] +pub struct DataConnectionRequest { + pub active: bool, +} + +#[derive(Debug, Default, Serialize)] +pub struct DataConnectionResponse { + pub active: bool, +} + +#[derive(Debug, Deserialize)] +pub struct RoamingRequest { + pub allowed: bool, +} + +#[derive(Debug, Default, Serialize)] +pub struct RoamingResponse { + pub roaming_allowed: bool, + pub is_roaming: bool, +} + +#[derive(Debug, Deserialize)] +pub struct AirplaneModeRequest { + pub enabled: bool, +} + +#[derive(Debug, Default, Serialize)] +pub struct AirplaneModeResponse { + pub enabled: bool, + pub powered: bool, + pub online: bool, +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct BasebandRestartStep { + pub step: String, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +#[derive(Debug, Default, Serialize)] +pub struct BasebandRestartResponse { + pub steps: Vec, + pub running: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub current_registration: Option, +} + +#[derive(Debug, Serialize, Clone)] +pub struct ThermalZone { + pub zone: String, + #[serde(rename = "type")] + pub sensor_type: String, + pub label: String, + pub temperature: f64, +} + +#[derive(Debug, Default, Serialize)] +pub struct SimInfoResponse { + pub present: bool, + pub iccid: String, + pub imsi: String, + pub phone_numbers: Vec, + pub sms_center: String, + pub mcc: String, + pub mnc: String, + pub phone_number_is_manual: bool, + pub sms_center_is_manual: bool, + pub sim_path: String, + pub modem_path: String, + pub sim_type: String, + pub esim_status: String, + pub active: bool, + pub operator_name: String, + pub registered_operator_name: String, + pub registered_operator_code: String, + pub lock_status: String, + pub pin1_retries: Option, + pub puk1_retries: Option, + pub pin2_retries: Option, + pub puk2_retries: Option, + pub carrier_config: String, + pub carrier_config_revision: String, + pub sms_used: Option, + pub sms_total: Option, +} + +#[derive(Debug, Default, Serialize)] +pub struct NetworkInfoResponse { + pub operator_name: String, + pub registration_status: String, + pub technology_preference: String, + pub signal_strength: u8, + #[serde(skip_serializing_if = "Option::is_none")] + pub mcc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mnc: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum RadioMode { + Auto, + #[serde(rename = "lte")] + LteOnly, + #[serde(rename = "nr")] + NrOnly, +} + +#[derive(Debug, Default, Serialize)] +pub struct RadioModeResponse { + pub mode: String, + pub technology_preference: String, + pub supported_modes: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct RadioModeRequest { + pub mode: RadioMode, +} + +#[derive(Debug, Default, Serialize)] +pub struct BandLockStatus { + pub locked: bool, + #[serde(default)] + pub supported_lte_fdd_bands: Vec, + #[serde(default)] + pub supported_lte_tdd_bands: Vec, + #[serde(default)] + pub supported_nr_fdd_bands: Vec, + #[serde(default)] + pub supported_nr_tdd_bands: Vec, + #[serde(default)] + pub lte_fdd_bands: Vec, + #[serde(default)] + pub lte_tdd_bands: Vec, + #[serde(default)] + pub nr_fdd_bands: Vec, + #[serde(default)] + pub nr_tdd_bands: Vec, +} + +#[derive(Debug, Deserialize, Default)] +pub struct BandLockRequest { + #[serde(default)] + pub lte_fdd_bands: Vec, + #[serde(default)] + pub lte_tdd_bands: Vec, + #[serde(default)] + pub nr_fdd_bands: Vec, + #[serde(default)] + pub nr_tdd_bands: Vec, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct CellLockRatStatus { + pub rat: u8, + pub rat_name: String, + pub enabled: bool, + pub lock_type: u8, + pub pci: Option, + pub arfcn: Option, +} + +#[derive(Debug, Default, Serialize)] +pub struct CellLockStatusResponse { + pub rat_status: Vec, + pub any_locked: bool, +} + +#[derive(Debug, Deserialize)] +pub struct CellLockRequest { + #[serde(default = "default_nr_rat")] + pub rat: u8, + pub enable: bool, + #[serde(default)] + pub lock_type: u8, + #[serde(default)] + pub pci: Option, + #[serde(default)] + pub arfcn: Option, +} + +fn default_nr_rat() -> u8 { + 16 +} + +#[derive(Debug, Deserialize)] +pub struct SystemRebootRequest { + #[serde(default)] + pub delay_seconds: u32, +} + +#[derive(Debug, Serialize, Clone)] +pub struct NetworkSpeed { + pub interface: String, + pub rx_bytes_per_sec: u64, + pub tx_bytes_per_sec: u64, + pub total_rx_bytes: u64, + pub total_tx_bytes: u64, +} + +#[derive(Debug, Default, Serialize, Clone)] +pub struct NetworkSpeedResponse { + pub interfaces: Vec, + pub interval_seconds: f64, +} + +#[derive(Debug, Default, Serialize, Clone)] +pub struct MemoryInfo { + pub total_bytes: u64, + pub available_bytes: u64, + pub used_bytes: u64, + pub used_percent: f64, + pub cached_bytes: u64, + pub buffers_bytes: u64, +} + +#[derive(Debug, Default, Serialize, Clone)] +pub struct UptimeInfo { + pub uptime_seconds: u64, + pub idle_seconds: u64, + pub uptime_formatted: String, +} + +#[derive(Debug, Default, Serialize, Clone)] +pub struct SystemInfo { + pub sysname: String, + pub nodename: String, + pub release: String, + pub version: String, + pub machine: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub domainname: String, + pub full_info: String, +} + +#[derive(Debug, Default, Serialize, Clone)] +pub struct SystemStatsResponse { + pub network_speed: NetworkSpeedResponse, + pub memory: MemoryInfo, + pub disk: Vec, + pub cpu_load: CpuLoadInfo, + pub uptime: UptimeInfo, + pub system_info: SystemInfo, + pub temperature: Vec, +} + +#[derive(Debug, Default, Serialize, Clone)] +pub struct DiskInfo { + pub mount_point: String, + pub fs_type: String, + pub total_bytes: u64, + pub used_bytes: u64, + pub available_bytes: u64, + pub used_percent: f64, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct PingResult { + pub success: bool, + pub latency_ms: Option, + pub target: String, + pub error: Option, +} + +#[derive(Debug, Default, Serialize)] +pub struct ConnectivityCheckResponse { + pub ipv4: PingResult, + pub ipv6: PingResult, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct DdnsStatusResponse { + pub enabled: bool, + pub running: bool, + pub provider: String, + pub last_sync_at: Option, + pub last_ipv4: Option, + pub last_ipv6: Option, + pub last_message: Option, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct DdnsLogEntry { + pub timestamp: String, + pub level: String, + pub record_type: String, + pub domains: Vec, + pub message: String, +} + +#[derive(Debug, Default, Serialize)] +pub struct DdnsLogsResponse { + pub entries: Vec, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct DdnsRecordSyncResult { + pub record_type: String, + pub domains: Vec, + pub old_ip: Option, + pub new_ip: Option, + pub status: String, + pub message: String, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct DdnsSyncResponse { + pub started_at: String, + pub finished_at: String, + pub records: Vec, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct DdnsEvent { + pub provider: String, + pub record_type: String, + pub domains: Vec, + pub old_ip: Option, + pub new_ip: Option, + pub status: String, + pub message: String, + pub timestamp: String, + pub failure_count: u32, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct WlanStatusResponse { + pub available: bool, + pub enabled: bool, + pub hardware_enabled: bool, + pub interface_name: Option, + pub connected: bool, + pub ssid: Option, + pub connection_id: Option, + pub ipv4_addresses: Vec, + pub ipv4_gateway: Option, + pub ipv6_addresses: Vec, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct WlanNetwork { + pub ssid: String, + pub bssid: String, + pub signal: u8, + pub security: String, + pub secure: bool, + pub connected: bool, +} + +#[derive(Debug, Default, Serialize)] +pub struct WlanScanResponse { + pub networks: Vec, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct WlanSavedNetwork { + pub id: String, + pub uuid: String, + pub ssid: String, + pub interface_name: Option, + pub active: bool, + pub auto_join: bool, +} + +#[derive(Debug, Default, Serialize)] +pub struct WlanProfilesResponse { + pub profiles: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct WlanEnabledRequest { + pub enabled: bool, +} + +#[derive(Debug, Deserialize)] +pub struct WlanConnectRequest { + pub ssid: String, + #[serde(default)] + pub password: String, + #[serde(default = "default_true_bool")] + pub auto_join: bool, +} + +#[derive(Debug, Deserialize)] +pub struct WlanProfileRequest { + pub connection_id: String, + #[serde(default)] + pub auto_join: Option, + #[serde(default)] + pub ipv4_mode: Option, + #[serde(default)] + pub ipv4_address: Option, + #[serde(default)] + pub ipv4_prefix: Option, + #[serde(default)] + pub ipv4_gateway: Option, +} + +#[derive(Debug, Deserialize)] +pub struct WlanForgetRequest { + #[serde(default)] + pub uuid: String, + #[serde(default)] + pub connection_id: String, +} + +fn default_true_bool() -> bool { + true +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct CpuLoadInfo { + pub load_1min: f64, + pub load_5min: f64, + pub load_15min: f64, + pub core_count: u32, + pub load_percent: f64, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct CpuCore { + pub processor: u32, + pub bogomips: String, + pub features: Vec, + pub implementer: String, + pub architecture: String, + pub variant: String, + pub part: String, + pub revision: String, +} + +#[derive(Debug, Default, Serialize)] +pub struct CpuInfo { + pub core_count: u32, + pub cores: Vec, + pub hardware: String, + pub serial: String, + pub model_name: String, +} + +#[derive(Debug, Serialize, Clone)] +pub struct IpAddress { + pub address: String, + pub prefix_len: u8, + pub ip_type: String, + pub scope: String, +} + +#[derive(Debug, Serialize, Clone)] +pub struct NetworkInterfaceInfo { + pub name: String, + pub status: String, + pub is_wireless: bool, + pub is_cellular: bool, + pub is_default_ipv4: bool, + pub is_default_ipv6: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub mac_address: Option, + pub mtu: u32, + pub ip_addresses: Vec, + pub rx_bytes: u64, + pub tx_bytes: u64, + pub rx_packets: u64, + pub tx_packets: u64, + pub rx_errors: u64, + pub tx_errors: u64, +} + +#[derive(Debug, Default, Serialize)] +pub struct NetworkInterfacesResponse { + pub interfaces: Vec, + pub total_count: usize, +} + +#[derive(Debug, Default, Serialize)] +pub struct ConnectionAddressesResponse { + pub ipv4: Vec, + pub ipv6: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub ipv4_interface: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ipv6_interface: Option, +} + +#[derive(Debug, Default, Serialize)] +pub struct SignalStrengthResponse { + pub strength: i32, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct CellLocationInfo { + pub mcc: String, + pub mnc: String, + pub lac: u32, + pub cid: u32, + pub signal_strength: i32, + pub radio_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub arfcn: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pci: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub rsrq: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sinr: Option, +} + +#[derive(Debug, Default, Serialize)] +pub struct CellLocationResponse { + pub available: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub cell_info: Option, + #[serde(default)] + pub neighbor_cells: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub cells: Option>, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct OperatorInfo { + pub path: String, + pub name: String, + pub status: String, + pub mcc: String, + pub mnc: String, + #[serde(default)] + pub technologies: Vec, +} + +#[derive(Debug, Default, Serialize)] +pub struct OperatorListResponse { + pub operators: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct ManualRegisterRequest { + pub mccmnc: String, +} + +#[derive(Debug, Default, Serialize)] +pub struct ApnContext { + pub path: String, + pub name: String, + pub active: bool, + pub apn: String, + pub protocol: String, + pub username: String, + pub password: String, + pub auth_method: String, + #[serde(default)] + pub context_type: String, +} + +#[derive(Debug, Default, Serialize)] +pub struct ApnListResponse { + pub contexts: Vec, +} + +#[derive(Debug, Deserialize, Default)] +pub struct SetApnRequest { + pub context_path: String, + pub apn: Option, + pub protocol: Option, + pub username: Option, + pub password: Option, + pub auth_method: Option, +} + +#[derive(Debug, Default, Serialize, Clone)] +pub struct CellLockResult { + pub success: bool, +} + +#[derive(Debug, Deserialize)] +pub struct MakeCallRequest { + pub phone_number: String, +} + +#[derive(Debug, Serialize, Clone, Default)] +pub struct CallInfo { + pub path: String, + pub phone_number: String, + pub state: String, + pub direction: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub start_time: Option, +} + +#[derive(Debug, Serialize, Default)] +pub struct CallListResponse { + pub calls: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct HangupCallRequest { + pub path: String, +} + +#[derive(Debug, Deserialize, Default)] +pub struct CallHistoryRequest { + #[serde(default = "default_page_size")] + pub limit: i64, + #[serde(default)] + pub offset: i64, +} + +#[derive(Debug, Serialize, Default)] +pub struct CallHistoryResponse { + pub records: Vec, + pub stats: CallStats, +} + +#[derive(Debug, Serialize, Default)] +pub struct CallVolumeResponse { + pub speaker_volume: u8, + pub microphone_volume: u8, + pub muted: bool, +} + +#[derive(Debug, Deserialize)] +pub struct SetCallVolumeRequest { + pub speaker_volume: Option, + pub microphone_volume: Option, + pub muted: Option, +} + +#[derive(Debug, Serialize, Default)] +pub struct CallSettingsResponse { + pub calling_line_presentation: String, + pub calling_name_presentation: String, + pub connected_line_presentation: String, + pub connected_line_restriction: String, + pub called_line_presentation: String, + pub calling_line_restriction: String, + pub hide_caller_id: String, + pub voice_call_waiting: String, +} + +#[derive(Debug, Deserialize)] +pub struct SetCallSettingRequest { + pub property: String, + pub value: String, +} + +#[derive(Debug, Serialize, Default)] +pub struct CallForwardingResponse { + pub voice_unconditional: String, + pub voice_busy: String, + pub voice_no_reply: String, + pub voice_no_reply_timeout: u16, + pub voice_not_reachable: String, + pub forwarding_flag_on_sim: bool, +} + +#[derive(Debug, Deserialize)] +pub struct SetCallForwardingRequest { + pub forward_type: String, + pub number: String, + pub timeout: Option, +} + +#[derive(Debug, Serialize, Default)] +pub struct ImsStatusResponse { + pub registered: bool, + pub voice_capable: bool, + pub sms_capable: bool, +} + +#[derive(Debug, Serialize, Default)] +pub struct VoicemailStatusResponse { + pub waiting: bool, + pub message_count: u8, + pub mailbox_number: String, +} + +#[derive(Debug, Deserialize)] +pub struct SendSmsRequest { + pub phone_number: String, + pub content: String, +} + +#[derive(Debug, Deserialize)] +pub struct SmsListRequest { + #[serde(default = "default_page_size")] + pub limit: i64, + #[serde(default)] + pub offset: i64, + pub direction: Option, +} + +#[derive(Debug, Deserialize)] +pub struct SmsConversationRequest { + pub phone_number: String, + #[serde(default = "default_page_size")] + pub limit: i64, +} + +#[derive(Debug, Default, Deserialize)] +pub struct SmsBatchDeleteRequest { + #[serde(default)] + pub ids: Vec, + #[serde(default)] + pub phone_numbers: Vec, +} + +fn default_page_size() -> i64 { + 50 +} + +#[derive(Debug, Default, Serialize)] +pub struct SmsListResponse { + pub messages: Vec, +} + +pub type SmsStatsResponse = SmsStats; + +#[derive(Debug, Serialize)] +pub struct WebhookTestResponse { + pub success: bool, + pub message: String, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct OtaMeta { + pub version: String, + pub commit: String, + pub build_time: String, + pub binary_md5: String, + pub frontend_md5: String, + pub arch: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub min_version: Option, +} + +#[derive(Debug, Default, Serialize)] +pub struct OtaStatusResponse { + pub current_version: String, + pub current_commit: String, + pub pending_update: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub pending_meta: Option, +} + +#[derive(Debug, Default, Serialize)] +pub struct OtaUploadResponse { + pub meta: OtaMeta, + pub validation: OtaValidation, +} + +#[derive(Debug, Default, Deserialize)] +pub struct OtaOnlinePrepareRequest { + pub proxy_prefix: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct OtaReleaseAsset { + pub name: String, + pub size: u64, + pub browser_download_url: String, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct OtaLatestReleaseResponse { + pub tag_name: String, + pub name: Option, + pub published_at: String, + pub target_commitish: Option, + pub body: Option, + pub html_url: Option, + pub assets: Vec, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct VersionUpdateEvent { + pub asset_name: String, + pub version: String, + pub build_time: String, + pub release_url: String, + pub timestamp: String, + pub own_number: String, +} + +#[derive(Debug, Default, Serialize)] +pub struct OtaValidation { + pub valid: bool, + pub is_newer: bool, + pub binary_md5_match: bool, + pub frontend_md5_match: bool, + pub arch_match: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Deserialize)] +pub struct OtaApplyRequest { + #[serde(default)] + pub restart_now: bool, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateSimCacheRequest { + pub phone_number: Option, + pub sms_center: Option, +} diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/backend/src/notification_queue.rs b/packages/operation-registry/test/fixtures/upstream-58e2204/backend/src/notification_queue.rs new file mode 100644 index 0000000..b19de02 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/backend/src/notification_queue.rs @@ -0,0 +1,119 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + Json, +}; +use serde::Deserialize; +use serde_json::json; +use std::sync::Arc; + +use crate::db::Database; +use crate::models::ApiResponse; + +#[derive(Debug, Default, Deserialize)] +pub struct NotificationQueueQuery { + #[serde(default = "default_notification_queue_limit")] + pub limit: i64, +} + +fn default_notification_queue_limit() -> i64 { + 100 +} + +/// GET /api/notifications/queue +pub async fn get_notification_queue_handler( + Query(query): Query, + State(database): State>, +) -> ( + StatusCode, + Json>, +) { + match database.get_notification_queue(query.limit) { + Ok(queue) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message("Success", queue)), + ), + Err(err) => ( + StatusCode::OK, + Json(ApiResponse::error(format!("Failed: {}", err))), + ), + } +} + +/// POST /api/notifications/queue/{id}/retry +pub async fn retry_notification_queue_item_handler( + Path(id): Path, + State(database): State>, +) -> (StatusCode, Json>) { + match database.retry_notification_queue_item(id) { + Ok(updated) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Notification queue item scheduled for retry", + json!({ "updated": updated }), + )), + ), + Err(err) => ( + StatusCode::OK, + Json(ApiResponse::error(format!("Failed: {}", err))), + ), + } +} + +/// DELETE /api/notifications/queue/{id} +pub async fn delete_notification_queue_item_handler( + Path(id): Path, + State(database): State>, +) -> (StatusCode, Json>) { + match database.delete_notification_queue_item(id) { + Ok(updated) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Notification queue item cancelled", + json!({ "updated": updated }), + )), + ), + Err(err) => ( + StatusCode::OK, + Json(ApiResponse::error(format!("Failed: {}", err))), + ), + } +} + +/// POST /api/notifications/queue/retry-all +pub async fn retry_all_notification_queue_handler( + State(database): State>, +) -> (StatusCode, Json>) { + match database.retry_all_notification_queue_items() { + Ok(updated) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Notification queue items scheduled for retry", + json!({ "updated": updated }), + )), + ), + Err(err) => ( + StatusCode::OK, + Json(ApiResponse::error(format!("Failed: {}", err))), + ), + } +} + +/// POST /api/notifications/queue/clear +pub async fn clear_notification_queue_handler( + State(database): State>, +) -> (StatusCode, Json>) { + match database.clear_active_notification_queue() { + Ok(updated) => ( + StatusCode::OK, + Json(ApiResponse::success_with_message( + "Notification queue cleared", + json!({ "updated": updated }), + )), + ), + Err(err) => ( + StatusCode::OK, + Json(ApiResponse::error(format!("Failed: {}", err))), + ), + } +} diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/backend/src/ota.rs b/packages/operation-registry/test/fixtures/upstream-58e2204/backend/src/ota.rs new file mode 100644 index 0000000..45e97ef --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/backend/src/ota.rs @@ -0,0 +1,839 @@ +//! OTA 更新模块 +//! +//! 处理 OTA 更新包的上传、验证和应用 + +use crate::config::ConfigManager; +use crate::models::{ + OtaLatestReleaseResponse, OtaMeta, OtaReleaseAsset, OtaStatusResponse, OtaUploadResponse, + OtaValidation, VersionUpdateEvent, +}; +use crate::notification::NotificationSender; +use chrono::{DateTime, FixedOffset, NaiveTime, TimeZone, Utc}; +use std::fs; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Arc; +use std::time::Duration; + +/// OTA 相关路径 +const OTA_STAGING_DIR: &str = "/tmp/ota_staging"; +const OTA_BINARY_PATH: &str = "/opt/simadmin/simadmin"; +const OTA_WWW_PATH: &str = "/opt/simadmin/www"; +const OTA_META_PATH: &str = "/opt/simadmin/meta.json"; +const OTA_SERVICE_NAME: &str = "simadmin.service"; +const NM_CONF_DIR: &str = "/etc/NetworkManager/conf.d"; +const NM_CONF_PATH: &str = "/etc/NetworkManager/conf.d/99-simadmin-unmanaged-modem.conf"; +const NM_UNMANAGED_WWAN_CONFIG: &str = "[keyfile]\nunmanaged-devices=interface-name:wwan*\n"; +const LATEST_RELEASE_API: &str = "https://api.github.com/repos/3899/SimAdmin/releases/latest"; +const BEIJING_UTC_OFFSET_SECONDS: i32 = 8 * 60 * 60; +const UPDATE_CHECK_HOURS: [u32; 2] = [9, 18]; +const OTA_HTTP_TIMEOUT_SECS: u64 = 30; +const BUILTIN_PROXY_PREFIXES: [&str; 3] = [ + "https://gh-proxy.com/", + "https://ghproxy.net/", + "https://githubproxy.cc/", +]; +pub const MAX_OTA_BYTES: u64 = 50 * 1024 * 1024; + +/// 当前版本信息(编译时注入) +pub const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// 获取当前 commit(从环境变量或默认值) +pub fn get_current_commit() -> String { + option_env!("GIT_COMMIT").unwrap_or("unknown").to_string() +} + +/// 获取 OTA 更新状态 +pub fn get_ota_status() -> OtaStatusResponse { + let pending_meta = read_pending_meta(); + + OtaStatusResponse { + current_version: CURRENT_VERSION.to_string(), + current_commit: get_current_commit(), + pending_update: pending_meta.is_some(), + pending_meta, + } +} + +pub fn duration_until_next_update_check() -> Duration { + duration_until_next_update_check_from(Utc::now()) +} + +fn duration_until_next_update_check_from(now_utc: DateTime) -> Duration { + let beijing = beijing_offset(); + let now = now_utc.with_timezone(&beijing); + + let next_check = UPDATE_CHECK_HOURS + .iter() + .filter_map(|hour| { + let check_time = NaiveTime::from_hms_opt(*hour, 0, 0)?; + beijing + .from_local_datetime(&now.date_naive().and_time(check_time)) + .single() + }) + .find(|check| now <= *check) + .unwrap_or_else(|| { + let check_time = NaiveTime::from_hms_opt(UPDATE_CHECK_HOURS[0], 0, 0) + .expect("valid update check time"); + beijing + .from_local_datetime( + &(now.date_naive() + chrono::Duration::days(1)).and_time(check_time), + ) + .single() + .expect("fixed offset has a single local time") + }); + + (next_check - now) + .to_std() + .unwrap_or_else(|_| Duration::from_secs(0)) +} + +pub fn normalize_proxy_prefix(prefix: Option) -> String { + let Some(prefix) = prefix else { + return String::new(); + }; + let prefix = prefix.trim(); + if prefix.is_empty() { + return String::new(); + } + if prefix.ends_with('/') { + prefix.to_string() + } else { + format!("{}/", prefix) + } +} + +pub fn build_ota_http_client() -> Result { + reqwest::Client::builder() + .user_agent("SimAdmin OTA updater") + .timeout(Duration::from_secs(OTA_HTTP_TIMEOUT_SECS)) + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e)) +} + +fn push_proxy_url(urls: &mut Vec, proxy_prefix: &str, url: &str) { + let proxy_prefix = normalize_proxy_prefix(Some(proxy_prefix.to_string())); + if proxy_prefix.is_empty() { + return; + } + let proxied_url = format!("{}{}", proxy_prefix, url); + if !urls.iter().any(|existing| existing == &proxied_url) { + urls.push(proxied_url); + } +} + +pub fn ota_request_urls( + url: &str, + proxy_prefix: &str, + include_builtin_proxies: bool, +) -> Vec { + let mut urls = Vec::new(); + push_proxy_url(&mut urls, proxy_prefix, url); + + if include_builtin_proxies { + for builtin_proxy in BUILTIN_PROXY_PREFIXES { + push_proxy_url(&mut urls, builtin_proxy, url); + } + } + + urls.push(url.to_string()); + urls +} + +pub fn is_supported_ota_asset(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + lower.ends_with(".tar.gz") || lower.ends_with(".tgz") || lower.ends_with(".zip") +} + +pub fn supported_release_asset(release: &OtaLatestReleaseResponse) -> Option<&OtaReleaseAsset> { + release + .assets + .iter() + .find(|asset| is_supported_ota_asset(&asset.name)) +} + +pub async fn fetch_latest_github_release( + client: &reqwest::Client, + proxy_prefix: &str, + include_builtin_proxies: bool, +) -> Result { + let mut last_error = String::new(); + for url in ota_request_urls(LATEST_RELEASE_API, proxy_prefix, include_builtin_proxies) { + match client + .get(&url) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .send() + .await + { + Ok(response) => { + let status = response.status(); + if !status.is_success() { + last_error = format!("GitHub Releases request failed: HTTP {}", status); + continue; + } + match response.json::().await { + Ok(release) => return Ok(release), + Err(e) => { + last_error = format!("Failed to parse latest release: {}", e); + continue; + } + } + } + Err(e) => { + last_error = format!("Failed to request latest release: {}", e); + } + } + } + + if last_error.is_empty() { + Err("GitHub Releases request failed".to_string()) + } else { + Err(last_error) + } +} + +pub async fn check_and_notify_version_update( + config_manager: Arc, + notification_sender: Arc, +) -> Result<(), String> { + let update_config = config_manager.get_version_update_notifications(); + if !update_config.enabled || !notification_sender.has_version_update_targets() { + return Ok(()); + } + + let proxy_prefix = normalize_proxy_prefix(Some(update_config.proxy_prefix)); + let client = build_ota_http_client()?; + let release = fetch_latest_github_release(&client, &proxy_prefix, true).await?; + + if !compare_versions(&release.tag_name, CURRENT_VERSION) { + return Ok(()); + } + + let notify_version = normalize_version(&release.tag_name); + if update_config + .last_notified_version + .as_deref() + .map(normalize_version) + .as_deref() + == Some(notify_version.as_str()) + { + return Ok(()); + } + + let asset = supported_release_asset(&release) + .ok_or_else(|| "No supported OTA asset found in latest release".to_string())?; + let own_number = notification_sender.get_own_number().await; + let current_time = chrono::Utc::now().to_rfc3339(); + let event = VersionUpdateEvent { + asset_name: asset.name.clone(), + version: notify_version.clone(), + build_time: current_time.clone(), + release_url: release.html_url.clone().unwrap_or_default(), + timestamp: current_time, + own_number, + }; + + let result = notification_sender + .forward_version_update_event(&event) + .await?; + + if result.delivered { + config_manager.set_last_notified_update_version(notify_version)?; + } + if !result.errors.is_empty() { + tracing::warn!( + errors = %result.errors.join("; "), + "Version update notification partially failed" + ); + } + + Ok(()) +} + + +pub async fn download_ota_asset_bytes( + client: &reqwest::Client, + proxy_prefix: &str, + include_builtin_proxies: bool, + asset: &OtaReleaseAsset, +) -> Result, String> { + if asset.size > MAX_OTA_BYTES { + return Err(format!( + "OTA asset is too large: {} bytes exceeds {} bytes", + asset.size, MAX_OTA_BYTES + )); + } + + let mut last_error = String::new(); + for download_url in ota_request_urls( + &asset.browser_download_url, + proxy_prefix, + include_builtin_proxies, + ) { + let response = match client.get(&download_url).send().await { + Ok(response) => response, + Err(e) => { + last_error = format!("Failed to download OTA asset: {}", e); + continue; + } + }; + + let status = response.status(); + if !status.is_success() { + last_error = format!("OTA asset download failed: HTTP {}", status); + continue; + } + + let bytes = match response.bytes().await { + Ok(bytes) => bytes, + Err(e) => { + last_error = format!("Failed to read OTA asset: {}", e); + continue; + } + }; + + if bytes.len() as u64 > MAX_OTA_BYTES { + last_error = format!( + "OTA asset is too large: {} bytes exceeds {} bytes", + bytes.len(), + MAX_OTA_BYTES + ); + continue; + } + + return Ok(bytes.to_vec()); + } + + if last_error.is_empty() { + Err("OTA asset download failed".to_string()) + } else { + Err(last_error) + } +} + + +/// 读取待安装的更新元数据 +fn read_pending_meta() -> Option { + let meta_path = format!("{}/meta.json", OTA_STAGING_DIR); + if let Ok(content) = fs::read_to_string(&meta_path) { + serde_json::from_str(&content).ok() + } else { + None + } +} + +/// 处理上传的 OTA 包(支持 tar.gz 和 zip 格式) +pub fn handle_ota_upload(data: &[u8]) -> Result { + // 清理并创建临时目录 + let _ = fs::remove_dir_all(OTA_STAGING_DIR); + fs::create_dir_all(OTA_STAGING_DIR) + .map_err(|e| format!("Failed to create staging dir: {}", e))?; + + // 自动检测文件格式 + let is_zip = detect_zip_format(data); + + if is_zip { + // ZIP 格式处理 + let zip_path = format!("{}/update.zip", OTA_STAGING_DIR); + let mut file = + fs::File::create(&zip_path).map_err(|e| format!("Failed to create zip file: {}", e))?; + file.write_all(data) + .map_err(|e| format!("Failed to write zip file: {}", e))?; + + // 解压 ZIP(使用 unzip 命令) + let output = Command::new("unzip") + .args(["-o", &zip_path, "-d", OTA_STAGING_DIR]) + .output() + .map_err(|e| { + format!( + "Failed to extract zip: {}. Make sure 'unzip' is installed.", + e + ) + })?; + + if !output.status.success() { + return Err(format!( + "Failed to extract zip: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + + // 删除原始 zip 文件 + let _ = fs::remove_file(&zip_path); + + // ZIP 格式不保留 Unix 权限,需要手动设置 + fix_file_permissions()?; + } else { + // TAR.GZ 格式处理(默认,保留 Unix 权限) + let tar_path = format!("{}/update.tar.gz", OTA_STAGING_DIR); + let mut file = + fs::File::create(&tar_path).map_err(|e| format!("Failed to create tar file: {}", e))?; + file.write_all(data) + .map_err(|e| format!("Failed to write tar file: {}", e))?; + + // 解压 TAR.GZ + let output = Command::new("tar") + .args(["-xzf", &tar_path, "-C", OTA_STAGING_DIR]) + .output() + .map_err(|e| format!("Failed to extract tar: {}", e))?; + + if !output.status.success() { + return Err(format!( + "Failed to extract tar: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + + // 删除原始 tar 文件 + let _ = fs::remove_file(&tar_path); + } + + // 读取 meta.json + let meta_path = format!("{}/meta.json", OTA_STAGING_DIR); + let meta_content = fs::read_to_string(&meta_path) + .map_err(|_| "meta.json not found in OTA package".to_string())?; + + let meta: OtaMeta = + serde_json::from_str(&meta_content).map_err(|e| format!("Invalid meta.json: {}", e))?; + + // 验证 + let validation = validate_ota_package(&meta)?; + + Ok(OtaUploadResponse { meta, validation }) +} + +/// 验证 OTA 包 +fn validate_ota_package(meta: &OtaMeta) -> Result { + let binary_path = format!("{}/simadmin", OTA_STAGING_DIR); + let www_path = format!("{}/www", OTA_STAGING_DIR); + + // 检查文件存在 + if !Path::new(&binary_path).exists() { + return Ok(OtaValidation { + valid: false, + is_newer: false, + binary_md5_match: false, + frontend_md5_match: false, + arch_match: false, + error: Some("Binary file not found in package".to_string()), + }); + } + + if !Path::new(&www_path).exists() { + return Ok(OtaValidation { + valid: false, + is_newer: false, + binary_md5_match: false, + frontend_md5_match: false, + arch_match: false, + error: Some("Frontend directory not found in package".to_string()), + }); + } + + // 计算二进制 MD5(严格验证) + let binary_md5 = calculate_file_md5(&binary_path)?; + let binary_md5_match = binary_md5 == meta.binary_md5; + + // 前端目录存在即可(MD5 跨平台难以保持一致) + let frontend_md5_match = true; // 跳过前端 MD5 验证 + + // 检查架构(只接受 musl) + let arch_match = meta.arch == "aarch64-unknown-linux-musl"; + + // 比较版本 + let is_newer = compare_versions(&meta.version, CURRENT_VERSION); + + // 只验证二进制 MD5 和架构 + let valid = binary_md5_match && arch_match; + + // 生成详细的错误信息 + let error = if !valid { + let mut errors = Vec::new(); + if !binary_md5_match { + errors.push(format!( + "Binary MD5 mismatch: expected={}, actual={}", + meta.binary_md5, binary_md5 + )); + } + if !arch_match { + errors.push(format!( + "Arch mismatch: expected=aarch64-unknown-linux-musl, actual={}", + meta.arch + )); + } + Some(errors.join("; ")) + } else { + None + }; + + Ok(OtaValidation { + valid, + is_newer, + binary_md5_match, + frontend_md5_match, + arch_match, + error, + }) +} + +/// 计算文件 MD5 +fn calculate_file_md5(path: &str) -> Result { + let mut file = fs::File::open(path).map_err(|e| format!("Failed to open file: {}", e))?; + + let mut contents = Vec::new(); + file.read_to_end(&mut contents) + .map_err(|e| format!("Failed to read file: {}", e))?; + + Ok(format!("{:x}", md5::compute(&contents))) +} + +/// 比较版本号(返回 v1 > v2) +pub fn compare_versions(v1: &str, v2: &str) -> bool { + let parse = |v: &str| -> Vec { + normalize_version(v) + .split(['.', '-']) + .filter_map(|s| s.parse().ok()) + .collect() + }; + + let v1_parts = parse(v1); + let v2_parts = parse(v2); + + for i in 0..std::cmp::max(v1_parts.len(), v2_parts.len()) { + let p1 = v1_parts.get(i).unwrap_or(&0); + let p2 = v2_parts.get(i).unwrap_or(&0); + if p1 > p2 { + return true; + } else if p1 < p2 { + return false; + } + } + false +} + +fn normalize_version(version: &str) -> String { + version.trim().trim_start_matches(['v', 'V']).to_string() +} + +fn beijing_offset() -> FixedOffset { + FixedOffset::east_opt(BEIJING_UTC_OFFSET_SECONDS).expect("valid Beijing UTC offset") +} + + +/// 应用 OTA 更新 +pub fn apply_ota_update(restart_now: bool) -> Result { + let meta = read_pending_meta().ok_or_else(|| "No pending update".to_string())?; + + let staging_binary = format!("{}/simadmin", OTA_STAGING_DIR); + let staging_www = format!("{}/www", OTA_STAGING_DIR); + + install_binary_atomic(&staging_binary, OTA_BINARY_PATH)?; + + // 复制前端文件(删除旧目录,复制新目录) + let _ = fs::remove_dir_all(OTA_WWW_PATH); + copy_dir_recursive(&staging_www, OTA_WWW_PATH)?; + chmod_www_tree(OTA_WWW_PATH)?; + + install_meta_file()?; + let nm_result = configure_networkmanager_modem_unmanaged(restart_now); + + // 清理暂存目录 + let _ = fs::remove_dir_all(OTA_STAGING_DIR); + + let message = format!( + "Update to version {} applied successfully; {}", + meta.version, nm_result + ); + + if restart_now { + std::thread::spawn(|| { + std::thread::sleep(std::time::Duration::from_secs(1)); + restart_service_no_block(); + }); + } + + Ok(message) +} + +fn configure_networkmanager_modem_unmanaged(restart_now: bool) -> String { + if !Path::new("/etc/NetworkManager").exists() { + return "NetworkManager not installed, unmanaged modem config skipped".to_string(); + } + + if let Ok(content) = fs::read_to_string(NM_CONF_PATH) { + if content == NM_UNMANAGED_WWAN_CONFIG { + return "NetworkManager already ignores wwan*".to_string(); + } + } + + if let Err(err) = fs::create_dir_all(NM_CONF_DIR) { + return format!("Failed to create NetworkManager conf.d: {err}"); + } + if let Err(err) = fs::write(NM_CONF_PATH, NM_UNMANAGED_WWAN_CONFIG) { + return format!("Failed to write NetworkManager unmanaged modem config: {err}"); + } + + if !restart_now { + return "NetworkManager config written; service restart deferred".to_string(); + } + + match Command::new("systemctl") + .args(["is-active", "--quiet", "NetworkManager.service"]) + .status() + { + Ok(status) if status.success() => { + match Command::new("systemctl") + .args(["restart", "NetworkManager.service"]) + .output() + { + Ok(output) if output.status.success() => { + "NetworkManager configured to ignore wwan*, service restarted".to_string() + } + Ok(output) => format!( + "NetworkManager config written, restart failed: {}", + String::from_utf8_lossy(&output.stderr) + ), + Err(err) => format!("NetworkManager config written, restart failed: {err}"), + } + } + _ => "NetworkManager configured to ignore wwan*, service not active".to_string(), + } +} + +fn install_binary_atomic(src: &str, dst: &str) -> Result<(), String> { + let dst_path = Path::new(dst); + let dst_dir = dst_path + .parent() + .ok_or_else(|| format!("Invalid binary path: {}", dst))?; + fs::create_dir_all(dst_dir).map_err(|e| format!("Failed to create binary dir: {}", e))?; + + let tmp_path = ota_temp_binary_path(dst_path); + let _ = fs::remove_file(&tmp_path); + + fs::copy(src, &tmp_path).map_err(|e| format!("Failed to copy binary: {}", e))?; + + let chmod_output = Command::new("chmod") + .arg("755") + .arg(&tmp_path) + .output() + .map_err(|e| { + let _ = fs::remove_file(&tmp_path); + format!("Failed to chmod: {}", e) + })?; + + if !chmod_output.status.success() { + let _ = fs::remove_file(&tmp_path); + return Err(format!( + "Failed to chmod: {}", + String::from_utf8_lossy(&chmod_output.stderr) + )); + } + + fs::rename(&tmp_path, dst).map_err(|e| { + let _ = fs::remove_file(&tmp_path); + format!("Failed to replace binary: {}", e) + })?; + + Ok(()) +} + +fn ota_temp_binary_path(dst: &Path) -> PathBuf { + let file_name = dst + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("simadmin"); + dst.with_file_name(format!(".{}.ota-new", file_name)) +} + +fn chmod_www_tree(path: &str) -> Result<(), String> { + let output = Command::new("chmod") + .args(["-R", "a+rX", path]) + .output() + .map_err(|e| format!("Failed to chmod www: {}", e))?; + + if !output.status.success() { + return Err(format!( + "Failed to chmod www: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + + Ok(()) +} + +fn install_meta_file() -> Result<(), String> { + let staging_meta = format!("{}/meta.json", OTA_STAGING_DIR); + if !Path::new(&staging_meta).exists() { + return Ok(()); + } + + fs::copy(&staging_meta, OTA_META_PATH) + .map_err(|e| format!("Failed to copy meta.json: {}", e))?; + + let output = Command::new("chmod") + .args(["644", OTA_META_PATH]) + .output() + .map_err(|e| format!("Failed to chmod meta.json: {}", e))?; + + if !output.status.success() { + return Err(format!( + "Failed to chmod meta.json: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + + Ok(()) +} + +fn restart_service_no_block() { + let _ = Command::new("systemctl") + .args(["--no-block", "restart", OTA_SERVICE_NAME]) + .output(); +} + +/// 递归复制目录 +fn copy_dir_recursive(src: &str, dst: &str) -> Result<(), String> { + fs::create_dir_all(dst).map_err(|e| format!("Failed to create dir: {}", e))?; + + let entries = fs::read_dir(src).map_err(|e| format!("Failed to read src dir: {}", e))?; + + for entry in entries { + let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?; + let src_path = entry.path(); + let dst_path = Path::new(dst).join(entry.file_name()); + + if src_path.is_dir() { + copy_dir_recursive( + src_path.to_str().unwrap_or(""), + dst_path.to_str().unwrap_or(""), + )?; + } else { + fs::copy(&src_path, &dst_path).map_err(|e| format!("Failed to copy file: {}", e))?; + } + } + + Ok(()) +} + +/// 取消待安装的更新 +pub fn cancel_pending_update() -> Result<(), String> { + if Path::new(OTA_STAGING_DIR).exists() { + fs::remove_dir_all(OTA_STAGING_DIR) + .map_err(|e| format!("Failed to remove staging dir: {}", e))?; + } + Ok(()) +} + +/// 检测文件是否为 ZIP 格式(通过魔术字节) +fn detect_zip_format(data: &[u8]) -> bool { + // ZIP 文件魔术字节: PK\x03\x04 (0x504B0304) + // TAR.GZ 文件魔术字节: \x1f\x8b (gzip header) + if data.len() < 4 { + return false; + } + + // 检查是否是 ZIP 格式 + data[0] == 0x50 && data[1] == 0x4B && data[2] == 0x03 && data[3] == 0x04 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compares_release_tag_versions() { + assert!(compare_versions("v1.0.4", "1.0.3")); + assert!(!compare_versions("v1.0.3", "1.0.3")); + assert!(!compare_versions("v1.0.2", "1.0.3")); + } + + #[test] + fn schedules_next_update_check_at_nine_and_eighteen_beijing_time() { + let before_nine = Utc.with_ymd_and_hms(2026, 5, 15, 0, 59, 0).unwrap(); + assert_eq!( + duration_until_next_update_check_from(before_nine), + Duration::from_secs(60) + ); + + let before_eighteen = Utc.with_ymd_and_hms(2026, 5, 15, 9, 59, 0).unwrap(); + assert_eq!( + duration_until_next_update_check_from(before_eighteen), + Duration::from_secs(60) + ); + + let after_eighteen = Utc.with_ymd_and_hms(2026, 5, 15, 10, 1, 0).unwrap(); + assert_eq!( + duration_until_next_update_check_from(after_eighteen), + Duration::from_secs(14 * 60 * 60 + 59 * 60) + ); + } + + #[test] + fn ota_request_urls_prefer_configured_proxy_then_builtin_then_direct() { + assert_eq!( + ota_request_urls("https://example.com/release", "https://proxy.local/", true), + vec![ + "https://proxy.local/https://example.com/release".to_string(), + "https://gh-proxy.com/https://example.com/release".to_string(), + "https://ghproxy.net/https://example.com/release".to_string(), + "https://githubproxy.cc/https://example.com/release".to_string(), + "https://example.com/release".to_string() + ] + ); + assert_eq!( + ota_request_urls("https://example.com/release", "https://gh-proxy.com", true), + vec![ + "https://gh-proxy.com/https://example.com/release".to_string(), + "https://ghproxy.net/https://example.com/release".to_string(), + "https://githubproxy.cc/https://example.com/release".to_string(), + "https://example.com/release".to_string() + ] + ); + } + + #[test] + fn ota_request_urls_can_skip_builtin_proxies() { + assert_eq!( + ota_request_urls("https://example.com/release", "", false), + vec!["https://example.com/release".to_string()] + ); + assert_eq!( + ota_request_urls("https://example.com/release", "https://proxy.local", false), + vec![ + "https://proxy.local/https://example.com/release".to_string(), + "https://example.com/release".to_string() + ] + ); + } +} + +/// 修复文件权限(用于 ZIP 解压后) +fn fix_file_permissions() -> Result<(), String> { + let binary_path = format!("{}/simadmin", OTA_STAGING_DIR); + let www_path = format!("{}/www", OTA_STAGING_DIR); + + // 设置二进制文件权限为 755(可执行) + if Path::new(&binary_path).exists() { + Command::new("chmod") + .args(["755", &binary_path]) + .output() + .map_err(|e| format!("Failed to chmod binary: {}", e))?; + } + + // 设置前端文件权限:目录 755,文件 644 + if Path::new(&www_path).exists() { + // 所有目录设置为 755 + let _ = Command::new("find") + .args([&www_path, "-type", "d", "-exec", "chmod", "755", "{}", "+"]) + .output(); + + // 所有文件设置为 644 + let _ = Command::new("find") + .args([&www_path, "-type", "f", "-exec", "chmod", "644", "{}", "+"]) + .output(); + } + + Ok(()) +} diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/clear_call_history.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/clear_call_history.bru new file mode 100644 index 0000000..14b5e3f --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/clear_call_history.bru @@ -0,0 +1,16 @@ +meta { + name: clear_call_history + type: http + seq: 3 +} + +post { + url: http://192.168.68.1:3000/api/call/history/clear + body: none + auth: none +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/delete_call_history.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/delete_call_history.bru new file mode 100644 index 0000000..b0e68dc --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/delete_call_history.bru @@ -0,0 +1,16 @@ +meta { + name: delete_call_history + type: http + seq: 2 +} + +delete { + url: http://192.168.68.1:3000/api/call/history/1 + body: none + auth: none +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_airplane_mode.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_airplane_mode.bru new file mode 100644 index 0000000..882aaff --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_airplane_mode.bru @@ -0,0 +1,17 @@ +meta { + name: get_airplane_mode + type: http + seq: 17 +} + +get { + url: http://192.168.68.1:3000/api/airplane-mode + body: none + auth: inherit +} + +settings { + encodeUrl: true +} + + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_apn_list.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_apn_list.bru new file mode 100644 index 0000000..686de48 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_apn_list.bru @@ -0,0 +1,16 @@ +meta { + name: Get APN List + type: http + seq: 80 +} + +get { + url: http://192.168.68.1:3000/api/apn + body: none + auth: inherit +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_band_lock.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_band_lock.bru new file mode 100644 index 0000000..3e3370e --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_band_lock.bru @@ -0,0 +1,17 @@ +meta { + name: get_band_lock + type: http + seq: 28 +} + +get { + url: http://192.168.68.1:3000/api/band-lock + body: none + auth: inherit +} + +settings { + encodeUrl: true +} + + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_call_forwarding.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_call_forwarding.bru new file mode 100644 index 0000000..f76feed --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_call_forwarding.bru @@ -0,0 +1,16 @@ +meta { + name: Get Call Forwarding + type: http + seq: 211 +} + +get { + url: http://192.168.68.1:3000/api/call/forwarding + body: none + auth: inherit +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_call_history.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_call_history.bru new file mode 100644 index 0000000..c97dbb0 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_call_history.bru @@ -0,0 +1,16 @@ +meta { + name: get_call_history + type: http + seq: 1 +} + +get { + url: http://192.168.68.1:3000/api/call/history?limit=50&offset=0 + body: none + auth: none +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_call_settings.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_call_settings.bru new file mode 100644 index 0000000..0b83646 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_call_settings.bru @@ -0,0 +1,16 @@ +meta { + name: Get Call Settings + type: http + seq: 213 +} + +get { + url: http://192.168.68.1:3000/api/call/settings + body: none + auth: inherit +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_call_volume.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_call_volume.bru new file mode 100644 index 0000000..da0f7cc --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_call_volume.bru @@ -0,0 +1,16 @@ +meta { + name: Get Call Volume + type: http + seq: 204 +} + +get { + url: http://192.168.68.1:3000/api/call/volume + body: none + auth: inherit +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_calls.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_calls.bru new file mode 100644 index 0000000..feea202 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_calls.bru @@ -0,0 +1,16 @@ +meta { + name: Get Calls + type: http + seq: 100 +} + +get { + url: http://192.168.68.1:3000/api/calls + body: none + auth: inherit +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_cell_location_info.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_cell_location_info.bru new file mode 100644 index 0000000..b0b6bd3 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_cell_location_info.bru @@ -0,0 +1,17 @@ +meta { + name: get_cell_location_info + type: http + seq: 25 +} + +get { + url: http://192.168.68.1:3000/api/location/cell-info + body: none + auth: inherit +} + +settings { + encodeUrl: true +} + + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_cell_lock.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_cell_lock.bru new file mode 100644 index 0000000..6540e98 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_cell_lock.bru @@ -0,0 +1,20 @@ +meta { + name: 获取小区锁定状态 + type: http + seq: 70 +} + +get { + url: http://192.168.68.1:3000/api/cell-lock + body: none + auth: inherit +} + +settings { + encodeUrl: true +} + + + + + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_cells_info.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_cells_info.bru new file mode 100644 index 0000000..89c91ae --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_cells_info.bru @@ -0,0 +1,15 @@ +meta { + name: get_cells_info + type: http + seq: 1 +} + +get { + url: http://192.168.68.1:3000/api/cells + body: none + auth: inherit +} + +settings { + encodeUrl: true +} diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_connectivity.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_connectivity.bru new file mode 100644 index 0000000..944a736 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_connectivity.bru @@ -0,0 +1,41 @@ +meta { + name: get_connectivity + type: http + seq: 1 +} + +get { + url: {{base_url}}/api/connectivity + body: none + auth: none +} + +docs { + # 联网检测 + + 通过 ping 检测 IPv4 和 IPv6 连通性。 + + ## 响应示例 + + ```json + { + "status": "ok", + "message": "Connectivity check completed", + "data": { + "ipv4": { + "success": true, + "latency_ms": 12.5, + "target": "223.5.5.5", + "error": null + }, + "ipv6": { + "success": true, + "latency_ms": 15.2, + "target": "2400:3200::1", + "error": null + } + } + } + ``` +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_cpu_info.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_cpu_info.bru new file mode 100644 index 0000000..ffb6f40 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_cpu_info.bru @@ -0,0 +1,17 @@ +meta { + name: get_cpu_info + type: http + seq: 23 +} + +get { + url: http://192.168.68.1:3000/api/stats/cpu + body: none + auth: inherit +} + +settings { + encodeUrl: true +} + + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_data_status.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_data_status.bru new file mode 100644 index 0000000..1431b19 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_data_status.bru @@ -0,0 +1,16 @@ +meta { + name: get_data_status + type: http + seq: 8 +} + +get { + url: http://192.168.68.1:3000/api/data + body: none + auth: inherit +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_device_ddns_config.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_device_ddns_config.bru new file mode 100644 index 0000000..d8f58a6 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_device_ddns_config.bru @@ -0,0 +1,15 @@ +meta { + name: get_device_ddns_config + type: http + seq: 80 +} + +get { + url: http://192.168.68.1:3000/api/device-network/ddns/config + body: none + auth: inherit +} + +settings { + encodeUrl: true +} diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_device_ddns_logs.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_device_ddns_logs.bru new file mode 100644 index 0000000..38631e2 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_device_ddns_logs.bru @@ -0,0 +1,15 @@ +meta { + name: get_device_ddns_logs + type: http + seq: 84 +} + +get { + url: http://192.168.68.1:3000/api/device-network/ddns/logs + body: none + auth: inherit +} + +settings { + encodeUrl: true +} diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_device_ddns_status.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_device_ddns_status.bru new file mode 100644 index 0000000..55d0d63 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_device_ddns_status.bru @@ -0,0 +1,15 @@ +meta { + name: get_device_ddns_status + type: http + seq: 82 +} + +get { + url: http://192.168.68.1:3000/api/device-network/ddns/status + body: none + auth: inherit +} + +settings { + encodeUrl: true +} diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_device_info.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_device_info.bru new file mode 100644 index 0000000..e0bf6ea --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_device_info.bru @@ -0,0 +1,16 @@ +meta { + name: get_device_info + type: http + seq: 3 +} + +get { + url: http://192.168.68.1:3000/api/device + body: none + auth: inherit +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_device_wlan_profiles.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_device_wlan_profiles.bru new file mode 100644 index 0000000..7834b08 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_device_wlan_profiles.bru @@ -0,0 +1,15 @@ +meta { + name: get_device_wlan_profiles + type: http + seq: 92 +} + +get { + url: http://192.168.68.1:3000/api/device-network/wlan/profiles + body: none + auth: inherit +} + +settings { + encodeUrl: true +} diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_device_wlan_status.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_device_wlan_status.bru new file mode 100644 index 0000000..e1e83c2 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_device_wlan_status.bru @@ -0,0 +1,15 @@ +meta { + name: get_device_wlan_status + type: http + seq: 85 +} + +get { + url: http://192.168.68.1:3000/api/device-network/wlan/status + body: none + auth: inherit +} + +settings { + encodeUrl: true +} diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_health.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_health.bru new file mode 100644 index 0000000..6a601d6 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_health.bru @@ -0,0 +1,16 @@ +meta { + name: get_health + type: http + seq: 16 +} + +get { + url: http://192.168.68.1:3000/api/health + body: none + auth: inherit +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_ims_status.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_ims_status.bru new file mode 100644 index 0000000..22fea31 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_ims_status.bru @@ -0,0 +1,16 @@ +meta { + name: Get IMS Status + type: http + seq: 203 +} + +get { + url: http://192.168.68.1:3000/api/ims/status + body: none + auth: inherit +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_network_info.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_network_info.bru new file mode 100644 index 0000000..926eddc --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_network_info.bru @@ -0,0 +1,16 @@ +meta { + name: get_network_info + type: http + seq: 5 +} + +get { + url: http://192.168.68.1:3000/api/network + body: none + auth: inherit +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_network_interfaces.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_network_interfaces.bru new file mode 100644 index 0000000..138308b --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_network_interfaces.bru @@ -0,0 +1,17 @@ +meta { + name: get_network_interfaces + type: http + seq: 26 +} + +get { + url: http://192.168.68.1:3000/api/network/interfaces + body: none + auth: inherit +} + +settings { + encodeUrl: true +} + + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_notification_config.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_notification_config.bru new file mode 100644 index 0000000..32578ec --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_notification_config.bru @@ -0,0 +1,15 @@ +meta { + name: get_notification_config + type: http + seq: 1 +} + +get { + url: http://192.168.68.1:3000/api/notifications/config + body: none + auth: none +} + +settings { + encodeUrl: true +} diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_operators.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_operators.bru new file mode 100644 index 0000000..e165880 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_operators.bru @@ -0,0 +1,16 @@ +meta { + name: Get Operators + type: http + seq: 207 +} + +get { + url: http://192.168.68.1:3000/api/network/operators + body: none + auth: inherit +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_ota_status.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_ota_status.bru new file mode 100644 index 0000000..7438e6b --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_ota_status.bru @@ -0,0 +1,26 @@ +meta { + name: Get OTA Status + type: http + seq: 200 +} + +get { + url: http://192.168.68.1:3000/api/ota/status + body: none + auth: inherit +} + +settings { + encodeUrl: true +} + +docs { + 获取 OTA 更新状态 + + 返回信息: + - current_version: 当前运行版本 + - current_commit: 当前 Git commit + - pending_update: 是否有待安装的更新 + - pending_meta: 待安装更新的元数据(如果有) +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_radio_mode.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_radio_mode.bru new file mode 100644 index 0000000..6033284 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_radio_mode.bru @@ -0,0 +1,17 @@ +meta { + name: get_radio_mode + type: http + seq: 24 +} + +get { + url: http://192.168.68.1:3000/api/radio-mode + body: none + auth: inherit +} + +settings { + encodeUrl: true +} + + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_roaming_status.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_roaming_status.bru new file mode 100644 index 0000000..e25a388 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_roaming_status.bru @@ -0,0 +1,17 @@ +meta { + name: get_roaming_status + type: http + seq: 10 +} + +get { + url: http://192.168.68.1:3000/api/roaming + body: none + auth: inherit +} + +settings { + encodeUrl: true +} + + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_signal_strength.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_signal_strength.bru new file mode 100644 index 0000000..6491a45 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_signal_strength.bru @@ -0,0 +1,16 @@ +meta { + name: Get Signal Strength + type: http + seq: 201 +} + +get { + url: http://192.168.68.1:3000/api/network/signal-strength + body: none + auth: inherit +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_sim_info.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_sim_info.bru new file mode 100644 index 0000000..028d38f --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_sim_info.bru @@ -0,0 +1,16 @@ +meta { + name: get_sim_info + type: http + seq: 4 +} + +get { + url: http://192.168.68.1:3000/api/sim + body: none + auth: inherit +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_sms_list.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_sms_list.bru new file mode 100644 index 0000000..b6f95f4 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_sms_list.bru @@ -0,0 +1,16 @@ +meta { + name: Get SMS List + type: http + seq: 111 +} + +get { + url: http://192.168.68.1:3000/api/sms/list?limit=50&offset=0 + body: none + auth: inherit +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_sms_stats.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_sms_stats.bru new file mode 100644 index 0000000..f1c97a2 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_sms_stats.bru @@ -0,0 +1,16 @@ +meta { + name: Get SMS Stats + type: http + seq: 112 +} + +get { + url: http://192.168.68.1:3000/api/sms/stats + body: none + auth: inherit +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_stats.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_stats.bru new file mode 100644 index 0000000..3e99de7 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_stats.bru @@ -0,0 +1,17 @@ +meta { + name: get_stats + type: http + seq: 24 +} + +get { + url: http://192.168.68.1:3000/api/stats + body: none + auth: inherit +} + +settings { + encodeUrl: true +} + + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_voicemail_status.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_voicemail_status.bru new file mode 100644 index 0000000..79576d2 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/get_voicemail_status.bru @@ -0,0 +1,16 @@ +meta { + name: Get Voicemail Status + type: http + seq: 206 +} + +get { + url: http://192.168.68.1:3000/api/voicemail/status + body: none + auth: inherit +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_call_answer.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_call_answer.bru new file mode 100644 index 0000000..f5a8bcf --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_call_answer.bru @@ -0,0 +1,22 @@ +meta { + name: Answer Call + type: http + seq: 104 +} + +post { + url: http://192.168.68.1:3000/api/call/answer + body: json + auth: inherit +} + +body:json { + { + "path": "/ril_0/voicecall01" + } +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_call_dial.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_call_dial.bru new file mode 100644 index 0000000..faa6af7 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_call_dial.bru @@ -0,0 +1,22 @@ +meta { + name: Dial Call + type: http + seq: 101 +} + +post { + url: http://192.168.68.1:3000/api/call/dial + body: json + auth: inherit +} + +body:json { + { + "phone_number": "10086" + } +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_call_hangup.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_call_hangup.bru new file mode 100644 index 0000000..a3fcc49 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_call_hangup.bru @@ -0,0 +1,22 @@ +meta { + name: Hangup Call + type: http + seq: 102 +} + +post { + url: http://192.168.68.1:3000/api/call/hangup + body: json + auth: inherit +} + +body:json { + { + "path": "/ril_0/voicecall01" + } +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_call_hangup_all.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_call_hangup_all.bru new file mode 100644 index 0000000..1eba40a --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_call_hangup_all.bru @@ -0,0 +1,16 @@ +meta { + name: Hangup All Calls + type: http + seq: 103 +} + +post { + url: http://192.168.68.1:3000/api/call/hangup-all + body: none + auth: inherit +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_device_ddns_logs_clear.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_device_ddns_logs_clear.bru new file mode 100644 index 0000000..0bd2fee --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_device_ddns_logs_clear.bru @@ -0,0 +1,19 @@ +meta { + name: post_device_ddns_logs_clear + type: http + seq: 91 +} + +post { + url: http://192.168.68.1:3000/api/device-network/ddns/logs/clear + body: json + auth: inherit +} + +body:json { + {} +} + +settings { + encodeUrl: true +} diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_device_ddns_sync.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_device_ddns_sync.bru new file mode 100644 index 0000000..847758d --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_device_ddns_sync.bru @@ -0,0 +1,19 @@ +meta { + name: post_device_ddns_sync + type: http + seq: 83 +} + +post { + url: http://192.168.68.1:3000/api/device-network/ddns/sync + body: json + auth: inherit +} + +body:json { + {} +} + +settings { + encodeUrl: true +} diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_device_wlan_connect.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_device_wlan_connect.bru new file mode 100644 index 0000000..3244243 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_device_wlan_connect.bru @@ -0,0 +1,23 @@ +meta { + name: post_device_wlan_connect + type: http + seq: 87 +} + +post { + url: http://192.168.68.1:3000/api/device-network/wlan/connect + body: json + auth: inherit +} + +body:json { + { + "ssid": "Your-WiFi", + "password": "your-password", + "auto_join": true + } +} + +settings { + encodeUrl: true +} diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_device_wlan_disconnect.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_device_wlan_disconnect.bru new file mode 100644 index 0000000..af7bc32 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_device_wlan_disconnect.bru @@ -0,0 +1,19 @@ +meta { + name: post_device_wlan_disconnect + type: http + seq: 89 +} + +post { + url: http://192.168.68.1:3000/api/device-network/wlan/disconnect + body: json + auth: inherit +} + +body:json { + {} +} + +settings { + encodeUrl: true +} diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_device_wlan_forget.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_device_wlan_forget.bru new file mode 100644 index 0000000..6f0c1d4 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_device_wlan_forget.bru @@ -0,0 +1,22 @@ +meta { + name: post_device_wlan_forget + type: http + seq: 93 +} + +post { + url: http://192.168.68.1:3000/api/device-network/wlan/forget + body: json + auth: inherit +} + +body:json { + { + "uuid": "saved-wifi-connection-uuid", + "connection_id": "Saved WiFi" + } +} + +settings { + encodeUrl: true +} diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_device_wlan_profile.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_device_wlan_profile.bru new file mode 100644 index 0000000..1d16151 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_device_wlan_profile.bru @@ -0,0 +1,23 @@ +meta { + name: post_device_wlan_profile + type: http + seq: 90 +} + +post { + url: http://192.168.68.1:3000/api/device-network/wlan/profile + body: json + auth: inherit +} + +body:json { + { + "connection_id": "Your-WiFi", + "auto_join": true, + "ipv4_mode": "dhcp" + } +} + +settings { + encodeUrl: true +} diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_device_wlan_scan.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_device_wlan_scan.bru new file mode 100644 index 0000000..3ace6fd --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_device_wlan_scan.bru @@ -0,0 +1,19 @@ +meta { + name: post_device_wlan_scan + type: http + seq: 86 +} + +post { + url: http://192.168.68.1:3000/api/device-network/wlan/scan + body: json + auth: inherit +} + +body:json { + {} +} + +settings { + encodeUrl: true +} diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_ota_apply.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_ota_apply.bru new file mode 100644 index 0000000..3505cca --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_ota_apply.bru @@ -0,0 +1,34 @@ +meta { + name: Apply OTA Update + type: http + seq: 202 +} + +post { + url: http://192.168.68.1:3000/api/ota/apply + body: json + auth: inherit +} + +body:json { + { + "restart_now": false + } +} + +settings { + encodeUrl: true +} + +docs { + 应用已上传的 OTA 更新 + + 参数: + - restart_now: 是否立即重启服务(true/false) + + 注意: + - 更新前必须先上传 OTA 包(/api/ota/upload) + - restart_now=true 时,服务会在 1 秒后自动重启 + - restart_now=false 时,需要手动重启服务才能生效 +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_ota_apply_restart.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_ota_apply_restart.bru new file mode 100644 index 0000000..35a3830 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_ota_apply_restart.bru @@ -0,0 +1,34 @@ +meta { + name: Apply OTA Update (with restart) + type: http + seq: 203 +} + +post { + url: http://192.168.68.1:3000/api/ota/apply + body: json + auth: inherit +} + +body:json { + { + "restart_now": true + } +} + +settings { + encodeUrl: true +} + +docs { + 应用已上传的 OTA 更新并立即重启服务 + + 参数: + - restart_now: true(立即重启) + + 警告: + - 执行此操作后,服务会在 1 秒后自动重启 + - 请确保已上传有效的 OTA 包 + - 重启期间所有连接会断开 +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_ota_cancel.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_ota_cancel.bru new file mode 100644 index 0000000..4614859 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_ota_cancel.bru @@ -0,0 +1,29 @@ +meta { + name: Cancel Pending OTA Update + type: http + seq: 204 +} + +post { + url: http://192.168.68.1:3000/api/ota/cancel + body: none + auth: inherit +} + +settings { + encodeUrl: true +} + +docs { + 取消待安装的 OTA 更新 + + 操作: + - 清除暂存目录中的 OTA 包 + - 删除待安装的元数据 + + 使用场景: + - 上传了错误的 OTA 包 + - 验证失败后需要重新上传 + - 决定不进行更新 +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_sms_clear.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_sms_clear.bru new file mode 100644 index 0000000..baa9a11 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_sms_clear.bru @@ -0,0 +1,16 @@ +meta { + name: Clear All SMS + type: http + seq: 113 +} + +post { + url: http://192.168.68.1:3000/api/sms/clear + body: none + auth: inherit +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_sms_send.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_sms_send.bru new file mode 100644 index 0000000..17b7f52 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_sms_send.bru @@ -0,0 +1,23 @@ +meta { + name: Send SMS + type: http + seq: 110 +} + +post { + url: http://192.168.68.1:3000/api/sms/send + body: json + auth: inherit +} + +body:json { + { + "phone_number": "10086", + "content": "测试短信" + } +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_system_reboot.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_system_reboot.bru new file mode 100644 index 0000000..5f488e5 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_system_reboot.bru @@ -0,0 +1,22 @@ +meta { + name: post_system_reboot + type: http + seq: 32 +} + +post { + url: http://192.168.68.1:3000/api/system/reboot + body: json + auth: inherit +} + +body:json { + { + "delay_seconds": 3 + } +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/register_operator_auto.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/register_operator_auto.bru new file mode 100644 index 0000000..f8afc90 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/register_operator_auto.bru @@ -0,0 +1,16 @@ +meta { + name: Register Operator Auto + type: http + seq: 210 +} + +post { + url: http://192.168.68.1:3000/api/network/register-auto + body: none + auth: inherit +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/register_operator_manual.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/register_operator_manual.bru new file mode 100644 index 0000000..608ce35 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/register_operator_manual.bru @@ -0,0 +1,22 @@ +meta { + name: Register Operator Manual + type: http + seq: 209 +} + +post { + url: http://192.168.68.1:3000/api/network/register-manual + body: json + auth: inherit +} + +body:json { + { + "mccmnc": "46001" + } +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/scan_operators.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/scan_operators.bru new file mode 100644 index 0000000..607cb4d --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/scan_operators.bru @@ -0,0 +1,16 @@ +meta { + name: Scan Operators (Slow) + type: http + seq: 208 +} + +get { + url: http://192.168.68.1:3000/api/network/operators/scan + body: none + auth: inherit +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_airplane_mode_disable.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_airplane_mode_disable.bru new file mode 100644 index 0000000..7fce8e1 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_airplane_mode_disable.bru @@ -0,0 +1,23 @@ +meta { + name: set_airplane_mode_disable + type: http + seq: 19 +} + +post { + url: http://192.168.68.1:3000/api/airplane-mode + body: json + auth: inherit +} + +body:json { + { + "enabled": false + } +} + +settings { + encodeUrl: true +} + + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_airplane_mode_enable.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_airplane_mode_enable.bru new file mode 100644 index 0000000..a121253 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_airplane_mode_enable.bru @@ -0,0 +1,23 @@ +meta { + name: set_airplane_mode_enable + type: http + seq: 18 +} + +post { + url: http://192.168.68.1:3000/api/airplane-mode + body: json + auth: inherit +} + +body:json { + { + "enabled": true + } +} + +settings { + encodeUrl: true +} + + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_apn.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_apn.bru new file mode 100644 index 0000000..8a8528a --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_apn.bru @@ -0,0 +1,27 @@ +meta { + name: Set APN Configuration + type: http + seq: 81 +} + +post { + url: http://192.168.68.1:3000/api/apn + body: json + auth: inherit +} + +body:json { + { + "context_path": "/ril_0/context2", + "apn": "cbnet", + "protocol": "dual", + "username": "", + "password": "", + "auth_method": "chap" + } +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_band_lock_lte_b1_b3.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_band_lock_lte_b1_b3.bru new file mode 100644 index 0000000..5e6d237 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_band_lock_lte_b1_b3.bru @@ -0,0 +1,26 @@ +meta { + name: set_band_lock_lte_b1_b3 + type: http + seq: 29 +} + +post { + url: http://192.168.68.1:3000/api/band-lock + body: json + auth: inherit +} + +body:json { + { + "lte_fdd_bands": [1, 3], + "lte_tdd_bands": [], + "nr_fdd_bands": [], + "nr_tdd_bands": [] + } +} + +settings { + encodeUrl: true +} + + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_band_lock_lte_nr_mix.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_band_lock_lte_nr_mix.bru new file mode 100644 index 0000000..b69127e --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_band_lock_lte_nr_mix.bru @@ -0,0 +1,26 @@ +meta { + name: set_band_lock_lte_nr_mix + type: http + seq: 31 +} + +post { + url: http://192.168.68.1:3000/api/band-lock + body: json + auth: inherit +} + +body:json { + { + "lte_fdd_bands": [1, 3, 8], + "lte_tdd_bands": [38, 40, 41], + "nr_fdd_bands": [1, 28], + "nr_tdd_bands": [41, 77, 78, 79] + } +} + +settings { + encodeUrl: true +} + + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_band_lock_nr_n78.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_band_lock_nr_n78.bru new file mode 100644 index 0000000..e8f9651 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_band_lock_nr_n78.bru @@ -0,0 +1,26 @@ +meta { + name: set_band_lock_nr_n78 + type: http + seq: 30 +} + +post { + url: http://192.168.68.1:3000/api/band-lock + body: json + auth: inherit +} + +body:json { + { + "lte_fdd_bands": [], + "lte_tdd_bands": [], + "nr_fdd_bands": [], + "nr_tdd_bands": [78] + } +} + +settings { + encodeUrl: true +} + + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_call_forwarding.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_call_forwarding.bru new file mode 100644 index 0000000..29fc267 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_call_forwarding.bru @@ -0,0 +1,23 @@ +meta { + name: Set Call Forwarding + type: http + seq: 212 +} + +post { + url: http://192.168.68.1:3000/api/call/forwarding + body: json + auth: inherit +} + +body:json { + { + "forward_type": "unconditional", + "number": "10086" + } +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_call_settings.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_call_settings.bru new file mode 100644 index 0000000..f0b848d --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_call_settings.bru @@ -0,0 +1,23 @@ +meta { + name: Set Call Settings + type: http + seq: 214 +} + +post { + url: http://192.168.68.1:3000/api/call/settings + body: json + auth: inherit +} + +body:json { + { + "property": "HideCallerId", + "value": "default" + } +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_call_volume.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_call_volume.bru new file mode 100644 index 0000000..468eeb6 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_call_volume.bru @@ -0,0 +1,24 @@ +meta { + name: Set Call Volume + type: http + seq: 205 +} + +post { + url: http://192.168.68.1:3000/api/call/volume + body: json + auth: inherit +} + +body:json { + { + "speaker_volume": 50, + "microphone_volume": 50, + "muted": false + } +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_cell_lock_lte.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_cell_lock_lte.bru new file mode 100644 index 0000000..df64f8a --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_cell_lock_lte.bru @@ -0,0 +1,29 @@ +meta { + name: 锁定LTE小区 + type: http + seq: 72 +} + +post { + url: http://192.168.68.1:3000/api/cell-lock + body: json + auth: inherit +} + +body:json { + { + "rat": 12, + "enable": true, + "arfcn": 100, + "pci": 100 + } +} + +settings { + encodeUrl: true +} + + + + + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_cell_lock_nr.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_cell_lock_nr.bru new file mode 100644 index 0000000..f5a5bed --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_cell_lock_nr.bru @@ -0,0 +1,29 @@ +meta { + name: 锁定NR小区 + type: http + seq: 71 +} + +post { + url: http://192.168.68.1:3000/api/cell-lock + body: json + auth: inherit +} + +body:json { + { + "rat": 16, + "enable": true, + "arfcn": 633984, + "pci": 599 + } +} + +settings { + encodeUrl: true +} + + + + + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_data_status_disable.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_data_status_disable.bru new file mode 100644 index 0000000..ff621ea --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_data_status_disable.bru @@ -0,0 +1,22 @@ +meta { + name: set_data_status_disable + type: http + seq: 10 +} + +post { + url: http://192.168.68.1:3000/api/data + body: json + auth: inherit +} + +body:json { + { + "active": false + } +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_data_status_enable.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_data_status_enable.bru new file mode 100644 index 0000000..b47608c --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_data_status_enable.bru @@ -0,0 +1,22 @@ +meta { + name: set_data_status_enable + type: http + seq: 9 +} + +post { + url: http://192.168.68.1:3000/api/data + body: json + auth: inherit +} + +body:json { + { + "active": true + } +} + +settings { + encodeUrl: true +} + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_device_ddns_config.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_device_ddns_config.bru new file mode 100644 index 0000000..6f8158a --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_device_ddns_config.bru @@ -0,0 +1,54 @@ +meta { + name: set_device_ddns_config + type: http + seq: 81 +} + +post { + url: http://192.168.68.1:3000/api/device-network/ddns/config + body: json + auth: inherit +} + +body:json { + { + "enabled": true, + "provider": "cloudflare", + "access_id": "your-cloudflare-zone-id", + "access_secret": "your-cloudflare-api-token", + "interval_seconds": 300, + "ttl": 600, + "ipv4": { + "enabled": true, + "get_type": "interface", + "interface_name": "eth0", + "urls": [ + "https://api.ipify.org", + "https://ip.3322.net", + "https://4.ident.me", + "https://ddns.oray.com/checkip", + "https://4.ipw.cn" + ], + "domains": [ + "home.example.com" + ] + }, + "ipv6": { + "enabled": false, + "get_type": "interface", + "interface_name": "wlan0", + "urls": [ + "https://api6.ipify.org", + "https://speed.neu6.edu.cn/getIP.php", + "https://v6.ident.me", + "https://myip6.ipip.net", + "https://6.ipw.cn" + ], + "domains": [] + } + } +} + +settings { + encodeUrl: true +} diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_device_wlan_enabled.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_device_wlan_enabled.bru new file mode 100644 index 0000000..47990b0 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_device_wlan_enabled.bru @@ -0,0 +1,21 @@ +meta { + name: set_device_wlan_enabled + type: http + seq: 88 +} + +post { + url: http://192.168.68.1:3000/api/device-network/wlan/enabled + body: json + auth: inherit +} + +body:json { + { + "enabled": true + } +} + +settings { + encodeUrl: true +} diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_notification_config.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_notification_config.bru new file mode 100644 index 0000000..86f92e1 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_notification_config.bru @@ -0,0 +1,44 @@ +meta { + name: set_notification_config + type: http + seq: 2 +} + +post { + url: http://192.168.68.1:3000/api/notifications/config + body: json + auth: none +} + +body:json { + { + "webhook": { + "enabled": true, + "url": "https://example.com/webhook", + "forward_sms": true, + "forward_calls": true, + "forward_ddns": true, + "headers": { + "Authorization": "Bearer your-token" + }, + "secret": "your-secret-key" + }, + "bark": { + "enabled": false, + "forward_sms": true, + "forward_calls": true, + "forward_ddns": true, + "server_url": "https://api.day.app", + "device_key": "", + "group": "", + "sound": "", + "level": "", + "auto_copy": true, + "save_history": true + } + } +} + +settings { + encodeUrl: true +} diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_radio_mode_auto.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_radio_mode_auto.bru new file mode 100644 index 0000000..5964d24 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_radio_mode_auto.bru @@ -0,0 +1,23 @@ +meta { + name: set_radio_mode_auto + type: http + seq: 25 +} + +post { + url: http://192.168.68.1:3000/api/radio-mode + body: json + auth: inherit +} + +body:json { + { + "mode": "auto" + } +} + +settings { + encodeUrl: true +} + + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_radio_mode_lte.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_radio_mode_lte.bru new file mode 100644 index 0000000..2da4f11 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_radio_mode_lte.bru @@ -0,0 +1,23 @@ +meta { + name: set_radio_mode_lte + type: http + seq: 26 +} + +post { + url: http://192.168.68.1:3000/api/radio-mode + body: json + auth: inherit +} + +body:json { + { + "mode": "lte" + } +} + +settings { + encodeUrl: true +} + + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_radio_mode_nr.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_radio_mode_nr.bru new file mode 100644 index 0000000..139d52d --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_radio_mode_nr.bru @@ -0,0 +1,23 @@ +meta { + name: set_radio_mode_nr + type: http + seq: 27 +} + +post { + url: http://192.168.68.1:3000/api/radio-mode + body: json + auth: inherit +} + +body:json { + { + "mode": "nr" + } +} + +settings { + encodeUrl: true +} + + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_roaming_disable.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_roaming_disable.bru new file mode 100644 index 0000000..dd6a8fc --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_roaming_disable.bru @@ -0,0 +1,23 @@ +meta { + name: set_roaming_disable + type: http + seq: 12 +} + +post { + url: http://192.168.68.1:3000/api/roaming + body: json + auth: inherit +} + +body:json { + { + "allowed": false + } +} + +settings { + encodeUrl: true +} + + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_roaming_enable.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_roaming_enable.bru new file mode 100644 index 0000000..3e62140 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/set_roaming_enable.bru @@ -0,0 +1,23 @@ +meta { + name: set_roaming_enable + type: http + seq: 11 +} + +post { + url: http://192.168.68.1:3000/api/roaming + body: json + auth: inherit +} + +body:json { + { + "allowed": true + } +} + +settings { + encodeUrl: true +} + + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/test_notification_channel.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/test_notification_channel.bru new file mode 100644 index 0000000..b3ad2ed --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/test_notification_channel.bru @@ -0,0 +1,35 @@ +meta { + name: test_notification_channel + type: http + seq: 3 +} + +post { + url: http://192.168.68.1:3000/api/notifications/test/{{channel}} + body: none + auth: none +} + +vars:pre-request { + channel: webhook +} + +docs { + # 测试指定通知渠道 + + `channel` 是通知渠道参数,不是独立接口名称。 + + 可用值: + - `webhook` + - `bark` + - `wecom_app` + - `wecom_robot` + - `dingtalk_robot` + - `dingtalk_app` + - `feishu_robot` + - `telegram` +} + +settings { + encodeUrl: true +} diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/unlock_all_bands.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/unlock_all_bands.bru new file mode 100644 index 0000000..c0dbcfd --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/unlock_all_bands.bru @@ -0,0 +1,26 @@ +meta { + name: unlock_all_bands + type: http + seq: 32 +} + +post { + url: http://192.168.68.1:3000/api/band-lock + body: json + auth: inherit +} + +body:json { + { + "lte_fdd_bands": [], + "lte_tdd_bands": [], + "nr_fdd_bands": [], + "nr_tdd_bands": [] + } +} + +settings { + encodeUrl: true +} + + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/unlock_all_cells.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/unlock_all_cells.bru new file mode 100644 index 0000000..c7bf0ec --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/unlock_all_cells.bru @@ -0,0 +1,24 @@ +meta { + name: 解锁所有小区 + type: http + seq: 74 +} + +post { + url: http://192.168.68.1:3000/api/cell-lock/unlock-all + body: json + auth: inherit +} + +body:json { + {} +} + +settings { + encodeUrl: true +} + + + + + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/unlock_cell_nr.bru b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/unlock_cell_nr.bru new file mode 100644 index 0000000..de03719 --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/unlock_cell_nr.bru @@ -0,0 +1,27 @@ +meta { + name: 解锁NR小区 + type: http + seq: 73 +} + +post { + url: http://192.168.68.1:3000/api/cell-lock + body: json + auth: inherit +} + +body:json { + { + "rat": 16, + "enable": false + } +} + +settings { + encodeUrl: true +} + + + + + diff --git a/packages/operation-registry/test/fixtures/upstream-58e2204/manifest.json b/packages/operation-registry/test/fixtures/upstream-58e2204/manifest.json new file mode 100644 index 0000000..876aa1e --- /dev/null +++ b/packages/operation-registry/test/fixtures/upstream-58e2204/manifest.json @@ -0,0 +1,99 @@ +{ + "upstreamCommit": "58e220411d6599609f0eeda01eb7016e9212f970", + "snapshotFormat": 1, + "brunoFileCount": 82, + "files": { + "backend/src/auth.rs": "c4960d75b4682c2ed8a0e4d860a2e87cd646cd77503ec2066995a04366418c6d", + "backend/src/config.rs": "fc998fa3ef24176d24125e7b4f5bd2aca2a9762d3cd16aa1f7db85608fe9979f", + "backend/src/device_network.rs": "3958c02be887b4f1c3414058299c32a31ed2f225c3020b6eb5c8a807fcd2c418", + "backend/src/esim.rs": "5f11e1a571c122e9809ae096dec27cb01ea62606facae9ae982f6a68af0a9776", + "backend/src/handlers.rs": "de42e03e41ab8da09aa1085d48f2337ebf5647db9c609e80dd12b32f34510376", + "backend/src/main.rs": "7eea98f36212a6174e94e316e6e319bff0fbf090d040983b73aa7aa9eb863c8e", + "backend/src/models.rs": "3b251f7416a7287afaf90776fa8cc7d0893d9b8ed6a2dfd6375f9c05da140090", + "backend/src/notification_queue.rs": "887ce67f4d5fb6edb6c5a404552048eaf2885fa7bc8be6d7a07c03f73c4431e1", + "backend/src/ota.rs": "f6f6b08f4bfad36f5262d72a7fba314517af0b76a1ade30006499c0c550f8f58", + "bruno-api/clear_call_history.bru": "f98e6c4af63aa72d5e2e0319df679d79f675cdfab8d08ea4fb894eac59d309a6", + "bruno-api/delete_call_history.bru": "763cac4ca664907a01e05f1d555360d5305b96ef8c5031ceca1958bde9933e32", + "bruno-api/get_airplane_mode.bru": "c435d579ae66e3249903803f42d81260fbb618d6255734c96e977fa1041de5c0", + "bruno-api/get_apn_list.bru": "34db7076861a5ad7fa5867fd042b0840e4800d4ed08f6b9276f254061a0c156d", + "bruno-api/get_band_lock.bru": "30327d7b32227339c204ac18d442ee02badfd19862f2215d8c2310045c2403ae", + "bruno-api/get_call_forwarding.bru": "f7da262de38036743e533799fbfa967c839b3f963339797be5f61ab613365f0b", + "bruno-api/get_call_history.bru": "36f7914da5a334d9aed020c2ccb8a586f580fe4389d1e5c456e3a9b58124c804", + "bruno-api/get_call_settings.bru": "9004fd78efcb144b1970da5634790031aecb9ddbb22d66476898f57b45ae26eb", + "bruno-api/get_call_volume.bru": "cbd14454ba165decbedcd1276b06126f7393838b189d338a6a4c80e395001ada", + "bruno-api/get_calls.bru": "2ad92484d7d3d4fa3ba1cfbfd715508a14ba9f841fe269175a2d1d7b9d0c1a48", + "bruno-api/get_cell_location_info.bru": "c17ffd72a6a7fc522be75f281d9986eb6685955d12af2c04c4a2372fd44d99ce", + "bruno-api/get_cell_lock.bru": "f2ad3efc9a9ac7e71a6acdb61c436a9bea47579dc876c434bad5ef03472083d1", + "bruno-api/get_cells_info.bru": "f87ee9c5b94005ea41c016204224f98763301670e4eb84b086929b9b48ab4387", + "bruno-api/get_connectivity.bru": "190b0e79b3402fcf9717de03d06da2adf57f47dae90c94cbb160bc4ae095594b", + "bruno-api/get_cpu_info.bru": "fa2d2e59dd04960de571c168e51248dc07a9fa7eabb05375a96788a496e52341", + "bruno-api/get_data_status.bru": "5197a60ce25149c9f8bfff6e5586f43c2d45ebcd13c96147c910bb927aeb50fb", + "bruno-api/get_device_ddns_config.bru": "b502ec49b25e4072050af240fde192f18e7f8f611ac3e39aaabdfa526b4502a1", + "bruno-api/get_device_ddns_logs.bru": "611ca74ebfd45545fec0248d08676a299eacc1e1a3f22ba260e3264b55509d0f", + "bruno-api/get_device_ddns_status.bru": "035f8633fe531fc4aff7c658730825bffc359a3b4d59e40d6517c83bf733d27f", + "bruno-api/get_device_info.bru": "49f4be82ef91cb1e479e204f5cab09a5aaab9f66c6866c5f202727f52a9d9d90", + "bruno-api/get_device_wlan_profiles.bru": "eb273643295a3cd200d262261862c0292cb856aed7164d7eb9ecd4d1931a7b3a", + "bruno-api/get_device_wlan_status.bru": "2559811eac9e308978417b9ba87ea195b299e4cd5b97dcd5bf50e2e38804c374", + "bruno-api/get_health.bru": "33c5816397b169f3e39cb12a216449fc73a0e88885641d94b643d1097a2973a7", + "bruno-api/get_ims_status.bru": "79f3e333ca6640a2a105eb47c0f7bbd06c73138afb7477d205192504d398c5e8", + "bruno-api/get_network_info.bru": "73e436031af6c79fe5a64d91d3b3853856a3d07d253cbb6ecee333e37ddcf8d9", + "bruno-api/get_network_interfaces.bru": "bbcb3f63fc23079cbbf31e3a02177027aa54d8a82e29da454ffa597be8056c06", + "bruno-api/get_notification_config.bru": "30c9ab47a63c4771f2cdb6efa9c4233ccaae3188b66c514ce69c5cf989944699", + "bruno-api/get_operators.bru": "64919ee811f5145a97748432cf388a837ad59a3d17156344175a0cf636f9bb7d", + "bruno-api/get_ota_status.bru": "6d63ec016942d191f3968c0614f5aff9d6ed88646e1a4ec53916d78161effd9a", + "bruno-api/get_radio_mode.bru": "eedd238adbde3989dbaf38b47fba3c76238528bb949ed62e5f70f2cac1b483b8", + "bruno-api/get_roaming_status.bru": "3f7bf6a0e4460a57f202a542b50cc3f8c634f89ffe8d7fb847fb6c0761e612f7", + "bruno-api/get_signal_strength.bru": "1720aaeb6f522af0bb4cbc0bf06b97b5ce6e06c935e827393ef13be8bc0d74ed", + "bruno-api/get_sim_info.bru": "94055040ad6be660960a06bebf38c1939545d95efe6415f036c149593ca238a2", + "bruno-api/get_sms_list.bru": "9c32941a92378832fbff323700a6538406f957adda0c59f92644015d0d5af2c9", + "bruno-api/get_sms_stats.bru": "b77d0ae2f716c95a7181525fa9629096aa3cda54fca2a1c924aeaa85edafaa51", + "bruno-api/get_stats.bru": "79647d3e56ae9246b02d18e1d421187b18098f14167a73e7145ccb8a70565b1d", + "bruno-api/get_voicemail_status.bru": "9d39b6ed21f99a36332260f6d9d175c8f691a66769687742497932f17281b4df", + "bruno-api/post_call_answer.bru": "dfe0376c35f04ba3f02a606e0e8c4d022118ef16602ca9c9d535b65443806bda", + "bruno-api/post_call_dial.bru": "3353df9e6937b3e52b897c29186ac26a653eab45b7a534a3dbbd66fc1bc06c4c", + "bruno-api/post_call_hangup.bru": "8370cccedb7640bf2e85b37fdbb43cc3409e4ae36c62bfa570fc9891b1a10203", + "bruno-api/post_call_hangup_all.bru": "97c3f186878394b900ec301d7be5133d30a2b98f3dc6cd611812bbba34a173b5", + "bruno-api/post_device_ddns_logs_clear.bru": "8ed8893ce7057bc9743e0ff4a77d40a3febebe63edd6d153007caba61f61f500", + "bruno-api/post_device_ddns_sync.bru": "affa36b0a3065e9900f1bfb24cb4c0a9d4c2fecefcd3d6d247df97ded9c9b192", + "bruno-api/post_device_wlan_connect.bru": "034295206a93396cfd9d4fc3eaaef8487aff3239ed0e78f2ab1eb397aa25bcc8", + "bruno-api/post_device_wlan_disconnect.bru": "cbcd0cea2dac83e31015f80ba433284b5776925ca69c7a9157e665b1abea9cdd", + "bruno-api/post_device_wlan_forget.bru": "76bc0db3cdacc3b86aa9c3b10439bac82fe6f58508b0ca3ce1d47e532569a5f7", + "bruno-api/post_device_wlan_profile.bru": "23874c871624f4997c48901b0ef1235f4fddeb0f5c1a51d718f1d216899a24d7", + "bruno-api/post_device_wlan_scan.bru": "0b12b8facb3991ddb90dcb2b4fef2972dcc23b7c8964c8b2c0038b0bd87b7000", + "bruno-api/post_ota_apply.bru": "f33f0bb193b8b6c0c5ae4cb12699f9392b60cc87ddd2f8ef90e2f5792d7a5194", + "bruno-api/post_ota_apply_restart.bru": "b45db76c1e1ada242011cc59f1bd1b616f1222043bc965087fe113def7214822", + "bruno-api/post_ota_cancel.bru": "b841db31a94e82f02204913760e5c1d0f7f6d6d9b2e0fff611cd5a405881ebaf", + "bruno-api/post_sms_clear.bru": "63b3d8f1d820e07897022cc1ec1b1b0f9c7312f81bd7cf33e593bbf3a38b359f", + "bruno-api/post_sms_send.bru": "50e94481363de8bbe35059730598c7fba5bea7649934a768dedce32637e1bd59", + "bruno-api/post_system_reboot.bru": "573dd9a8576abc7b9f60186f3e99f50ead8c180296ca3613f0fc1040174a4060", + "bruno-api/register_operator_auto.bru": "cd001ae45bd4d76a7733ec389a9aa15c27ca422f6f77c831509ce199f1f734f2", + "bruno-api/register_operator_manual.bru": "304e2cad15cea6a4004929ffcfc2f68d7953c7728a795fe51ef9b725db1318bd", + "bruno-api/scan_operators.bru": "bb337e21438c510697408fe35beb3a81d50c756c1b47ac4cdf7e9af50cdbdbfd", + "bruno-api/set_airplane_mode_disable.bru": "355f52ec18659e67cb71c5a471684fd6c588f903bd05e371e4c01dbcb4ceba3c", + "bruno-api/set_airplane_mode_enable.bru": "c3348f90354b5d624e4fd6ebf57b03462fc733ab80a2e6b8d62821711c314d57", + "bruno-api/set_apn.bru": "77c83e57b7ac9768dc85f68a0f984e7f09f6f626309437d1c496d0fab3fb1522", + "bruno-api/set_band_lock_lte_b1_b3.bru": "ce537a191a3e282953de864b8586bc15977a924eca267551aff2ef28ca032ab9", + "bruno-api/set_band_lock_lte_nr_mix.bru": "948a6253cfe3de0007ffdf5fdc25a7a0bab107059246c203ff81a3cf6b122b7d", + "bruno-api/set_band_lock_nr_n78.bru": "36b3be9a90f501b464954926517371b81af90bdc87f7db1ce8b25bfeee7fe9f2", + "bruno-api/set_call_forwarding.bru": "e8dd8f308e9e6be64469c2c4658a905c23cf0d12b681f5e1a48793dcc26a40e5", + "bruno-api/set_call_settings.bru": "3ca1aea68756c0ad016193332d0bfb2635fe356cb75e41441f37bde9b2cc6d94", + "bruno-api/set_call_volume.bru": "4d79026273eb76b7ccbaa0abb14d49faeb9112c32a3768b1baf78f425308e508", + "bruno-api/set_cell_lock_lte.bru": "bf15743756113ab74897795e1c0682eeea6c2bbca81c3557e0e53c5f4611a6a2", + "bruno-api/set_cell_lock_nr.bru": "3e910d6f96f42947fc9b668d082d4c052920dcdff93d7baa9e60202f0018af8f", + "bruno-api/set_data_status_disable.bru": "8688916e2ce401b949602f4a672cc11c8d2b44c97e0236e880dbd29519c123b5", + "bruno-api/set_data_status_enable.bru": "5a12f41e64e451979502b60b058375358a28b1b165d067995a4ccd6cb8907238", + "bruno-api/set_device_ddns_config.bru": "c13ea5e86986cf6a03fd06e95c50e5d492af0ac7232f5198aaa1c0138f4dd64f", + "bruno-api/set_device_wlan_enabled.bru": "d771ac8b6709b9dd62f38dc1ade1958e538ab3c01305baa2e9f2b87c927a2d2a", + "bruno-api/set_notification_config.bru": "026d7bab925384faad0d947ffd1dad89630a66bad78adb72d237d17f25692ace", + "bruno-api/set_radio_mode_auto.bru": "dceeccc49c336c82a8ff0fabefa9b2bb44ec7dbc3274c43d39cd9dc782032976", + "bruno-api/set_radio_mode_lte.bru": "cf821c094a8b60a8b5339ba889d85f4938033fca2bede6279fd9fb2ef5308d1e", + "bruno-api/set_radio_mode_nr.bru": "41d55ce5d18aa0e14e7729598fce1503040f2cb06a2933e997958aa0c9f7a7d7", + "bruno-api/set_roaming_disable.bru": "8991a96840470380bf50d8fedc08ccbd86dbadf95cbe6d9ee7fec9733c309d2b", + "bruno-api/set_roaming_enable.bru": "3a4ef29e7f646e0555545316ddb5afc14cf966984e91dea6979a7432ba453182", + "bruno-api/test_notification_channel.bru": "0f5ba9361bb55410e5d5cc4644ab9e6cf53ee7ea31525530db3b15aee9a83848", + "bruno-api/unlock_all_bands.bru": "3c82d066e73a51483c8e976c0469317881b20f823a6bc4c30a663eee51dc7449", + "bruno-api/unlock_all_cells.bru": "0206880d1c17e667a95cd9b8f12126a48c525106be2eb4e31eb9bb94e185e0d6", + "bruno-api/unlock_cell_nr.bru": "0e0d4504d6e9cee4e465d8a23e589cd21256d81c87c415cd8a97bd669cf09c19", + "backend/src/db.rs": "0608dd302c76574bf6bd4d33dd3d3077673d8bfc6fb55191859d7b4d275c8ce9" + } +} diff --git a/packages/operation-registry/test/route-parity.test.ts b/packages/operation-registry/test/route-parity.test.ts new file mode 100644 index 0000000..622cc71 --- /dev/null +++ b/packages/operation-registry/test/route-parity.test.ts @@ -0,0 +1,328 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +import { upstream58e2204Operations } from '../src/upstream-58e2204.ts'; + +const fixturePath = fileURLToPath(new URL('./fixtures/main-routes-58e2204.json', import.meta.url)); +const fixture = JSON.parse(await readFile(fixturePath, 'utf8')); +const key = (operation: { method: string; path?: string; pathTemplate?: string }) => + `${operation.method} ${operation.pathTemplate ?? operation.path}`; +const evidenceFixturePath = fileURLToPath(new URL('./fixtures/source-evidence-58e2204.json', import.meta.url)); +const evidenceFixture = JSON.parse(await readFile(evidenceFixturePath, 'utf8')); +const snapshotRoot = fileURLToPath(new URL('./fixtures/upstream-58e2204/', import.meta.url)); +const snapshotManifest = JSON.parse(await readFile(`${snapshotRoot}/manifest.json`, 'utf8')); +const requiredFields = [ + 'operationId', 'upstreamDomain', 'method', 'pathTemplate', 'handler', 'capability', 'riskLevel', + 'requestSchemaEvidence', 'responseSchemaEvidence', 'requestContentType', 'timeoutMs', + 'idempotency', 'confirmationPolicy', 'batchable', 'syncSafe', 'sensitiveFields', + 'auditedAtCommit', 'compatibilityAdapter', 'uiOwner', 'sourceEvidence', 'responseFixtureStatus', +] as const; + +const expectedDomains = new Set([ + 'instances-auth', 'device-system', 'sim', 'cellular', 'radio-lock', 'data-connection', + 'device-network', 'workmode-esim', 'messages', 'calls', 'notifications', 'automation', 'ota', +]); +const expectedOwners = new Set([ + 'instances-new', 'settings-instance', 'fleet', 'overview', 'cellular', 'device-network', + 'messages', 'calls', 'esim', 'notifications', 'automation', 'ota', +]); +const operationKey = (operation: { method: string; pathTemplate: string; handler: string }) => + `${operation.method} ${operation.pathTemplate} ${operation.handler}`; + +test('sensitive fields are independently snapshot-rebuildable model-chain anchors', async () => { + const directionClaim = (operation: any, direction: string) => + ['request', 'path', 'query'].includes(direction) ? operation.requestEvidence : operation.responseEvidence; + for (const operation of upstream58e2204Operations as any[]) for (const field of operation.sensitiveFields) { + const evidence = field.evidence; + assert.ok(evidence, `${operation.operationId}: ${field.path} missing evidence`); + assert.match(evidence.sourceSha256, /^[a-f0-9]{64}$/); + assert.ok(evidence.sourceFile && evidence.symbol && (evidence.fieldPath || evidence.modelField || evidence.dynamicKey)); + const source = await readFile(`${snapshotRoot}/${evidence.sourceFile}`, 'utf8'); + const slice = source.split('\n').slice(evidence.startLine - 1, evidence.endLine).join('\n'); + assert.equal(createHash('sha256').update(slice).digest('hex'), evidence.sourceSha256, `${operation.operationId}: ${field.path}`); + assert.ok(directionClaim(operation, field.direction), `${operation.operationId}: direction`); + if (evidence.handlerToken) assert.ok(slice.includes(evidence.handlerToken)); + else if (evidence.dynamicKey) assert.match(slice, new RegExp(`["']${evidence.dynamicKey}["']`)); + else { + assert.match(slice, new RegExp(`(?:struct|enum)\\s+${evidence.symbol}\\b`)); + assert.match(slice, new RegExp(`\\b${evidence.modelField}\\s*:`)); + assert.equal(evidence.modelField, field.path.replace(/\[\*\]/g, '').split('.').at(-1)); + for (const link of evidence.containerPath ?? []) { + const linkSource = await readFile(`${snapshotRoot}/${link.sourceFile}`, 'utf8'); + const linkSlice = linkSource.split('\n').slice(link.startLine - 1, link.endLine).join('\n'); + assert.equal(createHash('sha256').update(linkSlice).digest('hex'), link.sourceSha256); + assert.match(linkSlice, new RegExp(`struct\\s+${link.symbol}\\b`)); + assert.match(linkSlice, new RegExp(`\\b${link.field}\\s*:\\s*(?:Vec<)?${link.targetSymbol}`)); + } + } + } + const get = (method: string, path: string) => (upstream58e2204Operations as any[]).find(o => o.method === method && o.pathTemplate === path); + assert.ok(!get('GET','/api/sim').sensitiveFields.some((f: any) => f.path.endsWith('.imei'))); + assert.deepEqual(get('GET','/api/device-network/wlan/status').sensitiveFields.map((f: any) => f.path), [ + '$.response.data.ssid', '$.response.data.ipv4_addresses', '$.response.data.ipv6_addresses']); + assert.deepEqual(get('GET','/api/device-network/wlan/profiles').sensitiveFields.map((f: any) => f.path), ['$.response.data.profiles[*].ssid']); +}); + +test('Phase 0.2 uses structured, snapshot-rebuildable evidence and directional redaction contracts', () => { + for (const operation of upstream58e2204Operations as any[]) { + for (const name of ['requestEvidence', 'responseEvidence']) { + const claim = operation[name]; + assert.ok(claim && typeof claim === 'object', `${operation.operationId}: ${name}`); + assert.match(claim.kind, /^(none|path|query|json|multipart|bytes|explicit-return|api-response|dynamic-json|opaque)$/); + assert.match(claim.sourceSha256, /^[a-f0-9]{64}$/); + assert.ok(claim.sourceFile && Number.isInteger(claim.startLine) && Number.isInteger(claim.endLine)); + assert.ok(Array.isArray(claim.symbols)); + assert.ok(Array.isArray(claim.modelEvidence)); + } + for (const field of operation.sensitiveFields) { + assert.match(field.direction, /^(request|response|path|query|session)$/); + assert.match(field.path, /^\$\.(?:body|response|path|query|session)/); + assert.ok(field.reason); + assert.match(field.redactionMode, /^(drop|mask)$/); + assert.ok(field.sourceAnchor?.symbol || field.sourceAnchor?.token); + } + } + const get = (method: string, path: string) => (upstream58e2204Operations as any[]).find(o => o.method === method && o.pathTemplate === path); + assert.ok(get('GET', '/api/sms/list').responseEvidence.symbols.some((s: any) => s.symbol === 'SmsListResponse')); + assert.ok(get('GET', '/api/device').responseEvidence.symbols.some((s: any) => s.symbol === 'DeviceInfoResponse')); + assert.ok(get('GET', '/api/network').responseEvidence.symbols.some((s: any) => s.symbol === 'NetworkInfoResponse')); + assert.ok(get('POST', '/api/ota/upload').responseEvidence.symbols.some((s: any) => s.symbol === 'OtaUploadResponse')); +}); + +test('structured claims are rebuilt from the independent snapshot, with exact braces and symbols', async () => { + for (const operation of upstream58e2204Operations as any[]) for (const claim of [operation.requestEvidence, operation.responseEvidence]) { + const source = await readFile(`${snapshotRoot}/${claim.sourceFile}`, 'utf8'); + const slice = source.split('\n').slice(claim.startLine - 1, claim.endLine).join('\n'); + assert.equal(createHash('sha256').update(slice).digest('hex'), claim.sourceSha256, operation.operationId); + const fn = operation.handler.split('::').at(-1); const signature = new RegExp(`(?:pub\\s+)?async\\s+fn\\s+${fn}\\b`).exec(source); + assert.ok(signature); const open = source.indexOf('{', signature!.index); let depth = 0, close = -1; + for (let i = open; i < source.length; i++) { if (source[i] === '{') depth++; else if (source[i] === '}' && --depth === 0) { close = i; break; } } + assert.equal(source.slice(0, close + 1).split('\n').length, claim.endLine, `${operation.operationId}: exact end`); + for (const symbol of claim.symbols) assert.ok(slice.includes(symbol.symbol) || claim.modelEvidence.some((m: any) => m.symbol === symbol.symbol), `${operation.operationId}: ${symbol.symbol}`); + for (const model of claim.modelEvidence) { + const modelSource = await readFile(`${snapshotRoot}/${model.sourceFile}`, 'utf8'); + const modelSlice = modelSource.split('\n').slice(model.startLine - 1, model.endLine).join('\n'); + assert.equal(createHash('sha256').update(modelSlice).digest('hex'), model.sourceSha256, model.symbol); + assert.match(modelSlice, new RegExp(`(?:struct|enum)\\s+${model.symbol}\\b`)); + for (const field of model.fields) assert.match(modelSlice, new RegExp(`\\b${field}\\s*:`)); + } + if (claim.kind === 'dynamic-json') assert.match(slice, /json!|Json\s*\(|ApiResponse::/); + } +}); + +test('Axum route chains are independently parsed from frozen main.rs', async () => { + const source = await readFile(`${snapshotRoot}/backend/src/main.rs`, 'utf8'); const parsed: string[] = []; + for (let from = 0; ;) { + const start = source.indexOf('.route(', from); if (start < 0) break; let depth = 0, quote = false, end = -1; + for (let i = start + 6; i < source.length; i++) { const c = source[i]; if (c === '"' && source[i - 1] !== '\\') quote = !quote; if (!quote && c === '(') depth++; else if (!quote && c === ')' && --depth === 0) { end = i; break; } } + const chain = source.slice(start, end + 1); from = end + 1; const path = /\.route\(\s*"([^"]+)"/.exec(chain)?.[1]; if (!path) continue; + for (const match of chain.matchAll(/\.(get|post|delete)\(([\w:]+)\)|\b(get|post|delete)\(([\w:]+)\)/g)) { const method = (match[1] ?? match[3]).toUpperCase(); const handler = match[2] ?? match[4]; if (handler !== 'options_handler') parsed.push(`${method} ${path} ${handler}`); } + } + const expected = fixture.routes.map((r: any) => `${r.method} ${r.path} ${r.handler}`).sort(); + assert.deepEqual(parsed.sort(), expected); assert.deepEqual(parsed.sort(), (upstream58e2204Operations as any[]).map(operationKey).sort()); +}); + +test('Bruno files are independently parsed and agree with structured request claims', async () => { + for (const evidence of evidenceFixture.operations) for (const bruno of evidence.brunoEvidence ?? []) { + const text = await readFile(`${snapshotRoot}/${bruno.file}`, 'utf8'); const method = /\b(get|post|delete)\s*\{/.exec(text)?.[1].toUpperCase(); + const url = /url:\s*(?:\{\{baseUrl\}\}|https?:\/\/[^/\s]+)(\/[^\s]+)/.exec(text)?.[1]; assert.equal(method, bruno.method); assert.equal(url, bruno.path); + const bodyMode = /body:(json|multipart|text)/.exec(text)?.[1] ?? 'none'; assert.equal(bodyMode, bruno.bodyMode, bruno.file); + const operation: any = (upstream58e2204Operations as any[]).find(o => o.method === method && (o.pathTemplate === url || new RegExp(`^${o.pathTemplate.replace(/\{[^}]+\}/g, '[^/]+')}$`).test(url!))); assert.ok(operation, bruno.file); + if (bodyMode === 'json' && bruno.bodyFields.length) assert.equal(operation.requestEvidence.kind, 'json', bruno.file); + if (bodyMode === 'json' && !bruno.bodyFields.length) assert.ok(['none','path','query','json'].includes(operation.requestEvidence.kind), `${bruno.file}: empty Bruno JSON has no contract fields`); + for (const field of bruno.bodyFields) assert.match(text, new RegExp(`\\b${field}\\b`)); + } +}); + +test('frozen fixture has the audited upstream route totals', () => { + assert.equal(fixture.upstreamCommit, '58e220411d6599609f0eeda01eb7016e9212f970'); + assert.equal(fixture.routes.length, 117); + assert.equal(new Set(fixture.routes.map(key)).size, 117); + assert.equal(new Set(fixture.routes.map((route: { path: string }) => route.path)).size, 100); + assert.deepEqual( + Object.fromEntries(['GET', 'POST', 'DELETE'].map(method => [method, fixture.routes.filter((r: { method: string }) => r.method === method).length])), + { GET: 50, POST: 62, DELETE: 5 }, + ); +}); + +test('registry is exactly route-parity complete with independent fixture', () => { + assert.equal(upstream58e2204Operations.length, 117); + const fixtureTriples = fixture.routes.map((r: any) => `${r.method} ${r.path} ${r.handler}`).sort(); + assert.deepEqual(upstream58e2204Operations.map(operationKey).sort(), fixtureTriples); + assert.equal(new Set(upstream58e2204Operations.map(operation => operation.pathTemplate)).size, 100); + assert.equal(new Set(upstream58e2204Operations.map(operation => operation.operationId)).size, 117); + for (const operation of upstream58e2204Operations) { + const route = fixture.routes.find((r: any) => `${r.method} ${r.path} ${r.handler}` === operationKey(operation)); + assert.ok(route, operationKey(operation)); + assert.ok(operation.sourceEvidence.includes(route.source), `${operation.operationId}: exact main.rs evidence`); + } +}); + +test('frozen static-contract evidence is complete and bound to routes', () => { + assert.equal(evidenceFixture.upstreamCommit, fixture.upstreamCommit); + assert.equal(evidenceFixture.operations.length, 117); + assert.deepEqual(evidenceFixture.operations.map((e: any) => `${e.method} ${e.path} ${e.handler}`).sort(), + fixture.routes.map((r: any) => `${r.method} ${r.path} ${r.handler}`).sort()); + for (const evidence of evidenceFixture.operations) { + assert.match(evidence.handlerEvidence, /^backend\/src\/(?:handlers|auth|notification_queue)\.rs:\d+-\d+$/); + assert.match(evidence.sha256, /^[a-f0-9]{64}$/); + assert.ok(evidence.requestSummary && evidence.responseSummary); + } +}); + +test('source evidence is independently reproducible from frozen whole-file snapshots', async () => { + assert.equal(snapshotManifest.upstreamCommit, fixture.upstreamCommit); + assert.equal(snapshotManifest.brunoFileCount, 82); + for (const [file, expected] of Object.entries(snapshotManifest.files)) { + const bytes = await readFile(`${snapshotRoot}/${file}`); + assert.equal(createHash('sha256').update(bytes).digest('hex'), expected, file); + } + for (const evidence of evidenceFixture.operations) { + const range = evidence.handlerRange; + assert.ok(range && Number.isInteger(range.startLine) && Number.isInteger(range.endLine), evidence.handler); + const source = await readFile(`${snapshotRoot}/${range.file}`, 'utf8'); + const slice = source.split('\n').slice(range.startLine - 1, range.endLine).join('\n'); + assert.equal(createHash('sha256').update(slice).digest('hex'), evidence.sha256, evidence.handler); + assert.match(slice, new RegExp(`async\\s+fn\\s+${evidence.handler.split('::').at(-1)}\\b`)); + assert.equal((slice.match(/{/g) ?? []).length, (slice.match(/}/g) ?? []).length, evidence.handler); + assert.ok(evidence.extractorTokens.every((token: string) => slice.replace(/\\s+/g, '').includes(token.replace(/\\s+/g, ''))), evidence.handler); + assert.ok(evidence.responseKind && evidence.responseTypeEvidence !== 'Rust return impl IntoResponse', evidence.handler); + } +}); + +test('high-value response, confirmation, sensitive-field and Bruno contracts are fixed', () => { + const operations: any[] = upstream58e2204Operations as any; + const byPath = (method: string, path: string) => operations.find(o => o.method === method && o.pathTemplate === path)!; + assert.match(byPath('GET', '/api/device').responseSchemaEvidence, /ApiResponse/); + assert.match(byPath('GET', '/api/network').responseSchemaEvidence, /ApiResponse/); + assert.match(byPath('POST', '/api/ota/upload').responseSchemaEvidence, /ApiResponse/); + for (const operation of operations) { + if (operation.riskLevel === 'R2') assert.ok(['explicit', 'strong'].includes(operation.confirmationPolicy), operation.operationId); + if (operation.riskLevel === 'R3') assert.equal(operation.confirmationPolicy, 'strong', operation.operationId); + } + const expectedSensitive: Record = { + 'POST /api/esim/profiles': ['$.body.matching_id', '$.body.confirmation_code', '$.body.imei'], + 'POST /api/sms/send': ['$.body.phone_number', '$.body.content'], + 'GET /api/sms/conversation': ['$.query.phone_number'], + 'POST /api/call/dial': ['$.body.phone_number'], + 'POST /api/auth/setup': ['$.body.password'], + 'POST /api/auth/login': ['$.body.password'], + 'POST /api/auth/password': ['$.body.new_password'], + 'POST /api/device-network/wlan/connect': ['$.body.ssid', '$.body.password'], + }; + for (const [route, fields] of Object.entries(expectedSensitive)) { + const [method, path] = route.split(' '); + for (const field of fields) assert.ok(byPath(method, path).sensitiveFields.some((claim: any) => claim.path === field), `${route}: ${field}`); + } + for (const evidence of evidenceFixture.operations) { + assert.match(evidence.evidenceLevel, /^route\+handler(?:\+model)?(?:\+bruno)?$/); + if (evidence.brunoEvidence) assert.ok(evidence.brunoEvidence.length > 0); + } +}); + +test('every operation carries auditable orchestration metadata', () => { + for (const operation of upstream58e2204Operations) { + for (const field of requiredFields) assert.ok(field in operation, `${operation.operationId}: missing ${field}`); + assert.ok(expectedDomains.has(operation.upstreamDomain), `${operation.operationId}: unknown domain`); + assert.ok(expectedOwners.has(operation.uiOwner), `${operation.operationId}: unknown owner`); + assert.match(operation.operationId, /^[a-z][A-Za-z0-9]*$/); + assert.ok(['R0', 'R1', 'R2', 'R3'].includes(operation.riskLevel)); + assert.ok(Number.isInteger(operation.timeoutMs) && operation.timeoutMs > 0); + assert.ok(Array.isArray(operation.sensitiveFields)); + assert.ok(Array.isArray(operation.sourceEvidence) && operation.sourceEvidence.length > 0); + assert.ok(operation.sourceEvidence.some(evidence => /^backend\/src\/main\.rs:\d+-\d+$/.test(evidence))); + assert.ok(operation.handler && operation.requestSchemaEvidence && operation.responseSchemaEvidence); + for (const field of ['requestSchemaEvidence', 'responseSchemaEvidence', 'requestContentType', 'idempotency'] as const) { + assert.doesNotMatch(operation[field], /TODO_EVIDENCE|not frozen/i, `${operation.operationId}: ${field}`); + } + assert.ok(['safe', 'idempotent', 'non-idempotent', 'conditional'].includes(operation.idempotency)); + assert.equal(operation.auditedAtCommit, fixture.upstreamCommit); + assert.equal(operation.compatibilityAdapter, 'none-pinned-commit'); + if (operation.riskLevel === 'R2') assert.equal(operation.syncSafe, false); + if (operation.riskLevel === 'R3') { + assert.equal(operation.capability, 'job'); + assert.equal(operation.syncSafe, false); + assert.ok(['explicit', 'strong'].includes(operation.confirmationPolicy)); + } + if (operation.batchable && operation.riskLevel === 'R2') assert.equal(operation.capability, 'job'); + } +}); + +test('dangerous authentication endpoints use dedicated flows, never generic proxy', () => { + const dangerous = upstream58e2204Operations.filter(operation => + ['/api/auth/setup', '/api/auth/password'].includes(operation.pathTemplate)); + assert.equal(dangerous.length, 2); + for (const operation of dangerous) { + assert.equal(operation.riskLevel, 'R3'); + assert.equal(operation.executionPolicy, 'dedicatedFlow'); + } + const loginLogout = upstream58e2204Operations.filter(operation => + ['/api/auth/login', '/api/auth/logout'].includes(operation.pathTemplate)); + assert.equal(loginLogout.length, 2); + assert.ok(loginLogout.every(operation => operation.riskLevel === 'R1' && operation.sessionSensitive)); + const authSettingsPost = upstream58e2204Operations.find(operation => key(operation) === 'POST /api/auth/settings'); + assert.ok(authSettingsPost && ['R2', 'R3'].includes(authSettingsPost.riskLevel)); + assert.equal(authSettingsPost?.syncSafe, false); +}); + +test('product risk floors and auth replay policy are enforced', () => { + const byPath = (method: string, path: string) => upstream58e2204Operations.find(o => o.method === method && o.pathTemplate === path)!; + const r3 = [ + ['DELETE','/api/esim/profiles/{iccid}'], ['DELETE','/api/call/history/{id}'], ['POST','/api/call/history/clear'], + ['POST','/api/sms/batch-delete'], ['DELETE','/api/sms/conversation/{phone_number}'], ['DELETE','/api/sms/message/{id}'], ['POST','/api/sms/clear'], + ['POST','/api/notifications/logs/clear'], ['POST','/api/notifications/queue/clear'], ['DELETE','/api/notifications/queue/{id}'], + ['POST','/api/automation/logs/clear'], + ]; + for (const [method, path] of r3) { + const operation = byPath(method, path); + assert.equal(operation.riskLevel, 'R3', `${method} ${path}`); + assert.equal(operation.capability, 'job'); + assert.ok(['explicit', 'strong'].includes(operation.confirmationPolicy)); + } + const r2 = [ + ['POST','/api/sms/send'], ['POST','/api/notifications/test/{channel}'], + ['POST','/api/notifications/queue/retry-all'], ['POST','/api/notifications/queue/{id}/retry'], + ['POST','/api/automation/test/{task_id}'], + ]; + for (const [method, path] of r2) { + const operation = byPath(method, path); + assert.ok(['R2', 'R3'].includes(operation.riskLevel), `${method} ${path}`); + assert.equal(operation.capability, 'job'); + assert.equal(operation.syncSafe, false); + } + for (const path of ['/api/auth/login', '/api/auth/logout']) { + const operation = byPath('POST', path); + assert.equal(operation.executionPolicy, 'dedicatedFlow'); + assert.equal(operation.sessionSensitive, true); + assert.equal(operation.automaticReplay, 'forbidden'); + } + assert.equal(byPath('POST', '/api/auth/settings').executionPolicy, 'dedicatedFlow'); +}); + +test('evidence matrix lists every registry operation', async () => { + const documentPath = fileURLToPath(new URL('../../../docs/api/simadmin-upstream-58e2204.md', import.meta.url)); + const document = await readFile(documentPath, 'utf8'); + const rows = document.split('\n').filter(line => /^\| `(?:GET|POST|DELETE)` \|/.test(line)); + assert.equal(rows.length, 117); + for (const operation of upstream58e2204Operations) { + const row = rows.find(row => row.includes(`\`${operation.method}\``) && row.includes(`\`${operation.pathTemplate}\``) && row.includes(`\`${operation.operationId}\``)); + assert.ok(row, key(operation)); + const evidence = evidenceFixture.operations.find((e: any) => `${e.method} ${e.path} ${e.handler}` === operationKey(operation)); + assert.ok(evidence, operation.operationId); + for (const value of [operation.upstreamDomain, operation.uiOwner, operation.handler, operation.requestContentType, + operation.idempotency, operation.riskLevel, operation.capability, evidence.sha256.slice(0, 16)]) { + assert.ok(row.includes(value), `${operation.operationId}: document drift for ${value}`); + } + } + assert.doesNotMatch(document, /TODO_EVIDENCE|not frozen/i); + assert.match(document, /Phase 0\.3[^\n]*真实响应样本/); + assert.match(document, /dynamic-json/); + assert.match(document, /58e220411d6599609f0eeda01eb7016e9212f970/); + for (const operation of upstream58e2204Operations as any[]) for (const field of operation.sensitiveFields) { + const exactRow = `| ${operation.method} | \`${operation.pathTemplate}\` | ${field.direction} | \`${field.path}\` | ${field.redactionMode} | ${field.reason} |`; + assert.ok(document.includes(exactRow), `${operation.operationId}: sensitive ledger drift ${field.path}`); + } +});