An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(identity-wallet): add PlcMonitor types and error enum

+1307
+1
apps/identity-wallet/src-tauri/src/lib.rs
··· 7 7 pub mod oauth; 8 8 pub mod oauth_client; 9 9 pub mod pds_client; 10 + pub mod plc_monitor; 10 11 11 12 use crypto::{build_did_plc_genesis_op_with_external_signer, CryptoError, DidKeyUri}; 12 13 use serde::{Deserialize, Serialize};
+38
apps/identity-wallet/src-tauri/src/plc_monitor.rs
··· 1 + use serde::Serialize; 2 + 3 + /// An unauthorized PLC operation detected by the monitor. 4 + #[derive(Debug, Clone, Serialize)] 5 + #[serde(rename_all = "camelCase")] 6 + pub struct UnauthorizedChange { 7 + /// CID of the unauthorized operation. 8 + pub cid: String, 9 + /// ISO 8601 timestamp when plc.directory accepted the operation. 10 + /// Frontend computes recovery deadline as created_at + 72 hours. 11 + pub created_at: String, 12 + /// did:key URI of the key that signed this operation, if identified. 13 + /// None if the signing key could not be determined from known rotation keys. 14 + pub signing_key: Option<String>, 15 + /// The raw PLC operation JSON for display in alert detail. 16 + pub operation: serde_json::Value, 17 + } 18 + 19 + /// Result of checking a single identity's PLC status. 20 + #[derive(Debug, Clone, Serialize)] 21 + #[serde(rename_all = "camelCase")] 22 + pub struct IdentityStatus { 23 + pub did: String, 24 + pub alert_count: usize, 25 + pub unauthorized_changes: Vec<UnauthorizedChange>, 26 + } 27 + 28 + /// Errors from PLC monitoring operations. 29 + #[derive(Debug, thiserror::Error, Serialize)] 30 + #[serde(tag = "code", rename_all = "SCREAMING_SNAKE_CASE")] 31 + pub enum MonitorError { 32 + #[error("Network error: {message}")] 33 + NetworkError { message: String }, 34 + #[error("Identity store error: {message}")] 35 + IdentityStoreError { message: String }, 36 + #[error("Failed to parse audit log: {message}")] 37 + ParseError { message: String }, 38 + }
+415
docs/implementation-plans/2026-03-29-plc-monitoring-alerting/phase_01.md
··· 1 + # PLC Monitoring & Alerting Implementation Plan — Phase 1: PlcMonitor Backend Core 2 + 3 + **Goal:** Build the core monitoring logic that detects unauthorized PLC operations on managed identities. 4 + 5 + **Architecture:** A `PlcMonitor` struct in the identity-wallet Tauri backend that fetches audit logs from plc.directory, diffs against cached state, classifies new operations as authorized (signed by device key) or unauthorized (signed by any other key), and exposes the results via a Tauri IPC command. 6 + 7 + **Tech Stack:** Rust, Tauri v2 IPC, crypto crate (audit log parsing/diffing/verification), identity_store (Keychain-backed per-DID storage), pds_client (plc.directory HTTP client) 8 + 9 + **Scope:** 3 phases from design Phase 6. This is phase 1 of 3. 10 + 11 + **Codebase verified:** 2026-03-29 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + This phase implements and tests: 18 + 19 + ### plc-key-management.AC6: PLC monitoring and alerting 20 + - **plc-key-management.AC6.1 Success:** Monitor detects a new PLC operation signed by the device key and updates cached log without alerting 21 + - **plc-key-management.AC6.2 Success:** Monitor detects a new PLC operation signed by a different key and creates an `UnauthorizedChange` alert 22 + - **plc-key-management.AC6.3 Success:** Alert includes correct recovery deadline (operation timestamp + 72 hours) 23 + - **plc-key-management.AC6.7 Edge:** Monitor handles plc.directory being unreachable gracefully (logs error, retries next cycle, does not alert) 24 + - **plc-key-management.AC6.8 Edge:** Monitor handles empty audit log (newly created identity, no operations yet) 25 + 26 + --- 27 + 28 + ## Deviations from Design 29 + 30 + The design plan specifies a per-DID `check_for_changes(did)` command and particular field names on `UnauthorizedChange`. This implementation makes deliberate changes for simplicity: 31 + 32 + | Design | Implementation | Rationale | 33 + |--------|---------------|-----------| 34 + | `checkIdentityStatus(did: string)` per-DID call | `checkIdentityStatus()` no-arg, returns all identities | Avoids N+1 IPC calls; frontend gets complete state in one call | 35 + | `UnauthorizedChange.operationCid` | `UnauthorizedChange.cid` | Shorter, matches `AuditEntry.cid` field name from crypto crate | 36 + | `UnauthorizedChange.signedBy` | `UnauthorizedChange.signingKey` | Clearer that this is a did:key URI, not a display name | 37 + | `UnauthorizedChange.detectedAt` | Not included | `createdAt` (from plc.directory) is the authoritative timestamp; "detected" time adds no value since detection happens on poll | 38 + | `UnauthorizedChange.recoveryDeadline` | Not included (computed by frontend) | Avoids adding `chrono` dependency; deadline is deterministic from `createdAt + 72h`; frontend computes it for countdown display | 39 + | `UnauthorizedChange.description` | Not included | Raw `operation` JSON provides full details; frontend renders the relevant fields | 40 + | `IdentityStatus.healthy` | Not included | Monitoring errors are gracefully handled (AC6.7: return empty, no alert); per-identity "health" would be ambiguous | 41 + 42 + --- 43 + 44 + ## Codebase Verification Findings 45 + 46 + - ✓ `plc_monitor.rs` does NOT exist — new file to create 47 + - ✓ `crypto::parse_audit_log(json: &str) -> Result<Vec<AuditEntry>, CryptoError>` exists at `crates/crypto/src/plc.rs:606` 48 + - ✓ `crypto::diff_audit_logs(cached: &[AuditEntry], current: &[AuditEntry]) -> Vec<AuditEntry>` exists at `crates/crypto/src/plc.rs:614` 49 + - ✓ `crypto::verify_plc_operation(signed_op_json: &str, authorized_rotation_keys: &[DidKeyUri]) -> Result<VerifiedPlcOp, CryptoError>` exists at `crates/crypto/src/plc.rs:463` 50 + - ✓ `AuditEntry { did, cid, created_at, nullified, operation }` at `crates/crypto/src/plc.rs:585` 51 + - ✓ `IdentityStore::list_identities() -> Result<Vec<String>, IdentityStoreError>` at `identity_store.rs:189` 52 + - ✓ `IdentityStore::get_plc_log(did) -> Result<Option<String>, IdentityStoreError>` at `identity_store.rs:277` 53 + - ✓ `IdentityStore::store_plc_log(did, json) -> Result<(), IdentityStoreError>` at `identity_store.rs:261` 54 + - ✓ `IdentityStore::get_or_create_device_key(did) -> Result<DevicePublicKey, IdentityStoreError>` at `identity_store.rs:202` 55 + - ✓ `PdsClient::fetch_audit_log(did) -> Result<String, PdsClientError>` at `pds_client.rs:452` 56 + - ✓ `PdsClient` is in `AppState` (eagerly initialized), accessed via `state.pds_client()` 57 + - ✓ `VerifiedPlcOp` does NOT include a signing key field — must iterate candidate keys to identify signer 58 + - ✓ `DevicePublicKey.key_id` contains the full `did:key:...` URI 59 + - ✓ Error pattern: `thiserror::Error` + `serde(tag = "code", rename_all = "SCREAMING_SNAKE_CASE")` 60 + - ✓ Testing: `#[cfg(test)]` modules, `httpmock::MockServer`, `#[tokio::test]`, in-memory Keychain mock 61 + - ✓ No `chrono` dependency — `created_at` is raw ISO 8601 string; 72h deadline computed by frontend 62 + 63 + ## External Dependency Findings 64 + 65 + - ✓ plc.directory audit log: JSON array of `{ operation, did, cid, createdAt, nullified }` entries 66 + - ✓ Signing key not in operation JSON — must try each rotation key from previous op via `verify_plc_operation` 67 + - ✓ 72-hour recovery window: defined in did:plc spec v0.1; higher-authority key can rewrite history within 72h of `createdAt` 68 + - ✓ `createdAt` format: ISO 8601 `YYYY-MM-DDTHH:mm:ss.sssZ` 69 + 70 + --- 71 + 72 + <!-- START_SUBCOMPONENT_A (tasks 1-4) --> 73 + 74 + <!-- START_TASK_1 --> 75 + ### Task 1: Create PlcMonitor types and error enum 76 + 77 + **Verifies:** None (infrastructure for subsequent tasks) 78 + 79 + **Files:** 80 + - Create: `apps/identity-wallet/src-tauri/src/plc_monitor.rs` 81 + - Modify: `apps/identity-wallet/src-tauri/src/lib.rs` (add `mod plc_monitor;` declaration) 82 + 83 + **Implementation:** 84 + 85 + Create `plc_monitor.rs` with the following types: 86 + 87 + ```rust 88 + use serde::Serialize; 89 + 90 + /// An unauthorized PLC operation detected by the monitor. 91 + #[derive(Debug, Clone, Serialize)] 92 + #[serde(rename_all = "camelCase")] 93 + pub struct UnauthorizedChange { 94 + /// CID of the unauthorized operation. 95 + pub cid: String, 96 + /// ISO 8601 timestamp when plc.directory accepted the operation. 97 + /// Frontend computes recovery deadline as created_at + 72 hours. 98 + pub created_at: String, 99 + /// did:key URI of the key that signed this operation, if identified. 100 + /// None if the signing key could not be determined from known rotation keys. 101 + pub signing_key: Option<String>, 102 + /// The raw PLC operation JSON for display in alert detail. 103 + pub operation: serde_json::Value, 104 + } 105 + 106 + /// Result of checking a single identity's PLC status. 107 + #[derive(Debug, Clone, Serialize)] 108 + #[serde(rename_all = "camelCase")] 109 + pub struct IdentityStatus { 110 + pub did: String, 111 + pub alert_count: usize, 112 + pub unauthorized_changes: Vec<UnauthorizedChange>, 113 + } 114 + 115 + /// Errors from PLC monitoring operations. 116 + #[derive(Debug, thiserror::Error, Serialize)] 117 + #[serde(tag = "code", rename_all = "SCREAMING_SNAKE_CASE")] 118 + pub enum MonitorError { 119 + #[error("Network error: {message}")] 120 + NetworkError { message: String }, 121 + #[error("Identity store error: {message}")] 122 + IdentityStoreError { message: String }, 123 + #[error("Failed to parse audit log: {message}")] 124 + ParseError { message: String }, 125 + } 126 + ``` 127 + 128 + Add `mod plc_monitor;` to `lib.rs` alongside the other module declarations (near `mod claim;`, `mod identity_store;`, etc.). 129 + 130 + **Verification:** 131 + 132 + Run: `cd apps/identity-wallet/src-tauri && cargo check` 133 + Expected: Compiles without errors 134 + 135 + **Commit:** `feat(identity-wallet): add PlcMonitor types and error enum` 136 + 137 + <!-- END_TASK_1 --> 138 + 139 + <!-- START_TASK_2 --> 140 + ### Task 2: Implement PlcMonitor::check_for_changes 141 + 142 + **Verifies:** plc-key-management.AC6.1, plc-key-management.AC6.2, plc-key-management.AC6.3, plc-key-management.AC6.7, plc-key-management.AC6.8 143 + 144 + **Files:** 145 + - Modify: `apps/identity-wallet/src-tauri/src/plc_monitor.rs` 146 + 147 + **Implementation:** 148 + 149 + Add `PlcMonitor` struct and `check_for_changes` method. The struct holds a cloned `PdsClient` (cheap — wraps `reqwest::Client` + URL string). 150 + 151 + `check_for_changes` algorithm: 152 + 1. Fetch current audit log from plc.directory via `pds_client.fetch_audit_log(did)`. On network error, log with `tracing::warn!` and return `Ok(vec![])` (AC6.7 — graceful handling, no alert). 153 + 2. Parse current log via `crypto::parse_audit_log`. On parse error, log warning and return `Ok(vec![])`. 154 + 3. Load cached log from `IdentityStore::get_plc_log(did)`. If `None` (first check or empty — AC6.8), parse as empty `Vec<AuditEntry>`. 155 + 4. Diff via `crypto::diff_audit_logs(cached, current)` to get new entries. 156 + 5. If no new entries, return `Ok(vec![])`. 157 + 6. Get the device key's did:key URI via `IdentityStore::get_or_create_device_key(did)` → `DevicePublicKey.key_id`. 158 + 7. For each new `AuditEntry`: 159 + a. Serialize `entry.operation` to JSON string. 160 + b. Call `crypto::verify_plc_operation(op_json, &[DidKeyUri(device_key_uri)])` (wrapping String in DidKeyUri newtype). 161 + c. If `Ok(_)` → authorized, skip (AC6.1). 162 + d. If `Err(_)` → unauthorized (AC6.2). Attempt to identify the signing key by trying each key in the previous operation's `rotationKeys`. Build `UnauthorizedChange` with `created_at` from the entry (AC6.3 — frontend computes deadline from this timestamp). 163 + 8. Update cached log: `IdentityStore::store_plc_log(did, &current_log_json)` (stores the full fetched log for next diff). 164 + 9. Return the list of `UnauthorizedChange` entries. 165 + 166 + To get the previous operation's `rotationKeys` for signing key identification (step 7d): 167 + - The previous entry in the current audit log (the entry just before the new one) contains the authoritative `rotationKeys`. 168 + - Parse its `operation` field to extract `rotationKeys` array. 169 + - Try `verify_plc_operation` with each key individually. The one that succeeds is the signing key. 170 + - If none succeed, set `signing_key: None`. 171 + 172 + ```rust 173 + use crate::identity_store::IdentityStore; 174 + use crate::pds_client::PdsClient; 175 + use crypto::{parse_audit_log, diff_audit_logs, verify_plc_operation, AuditEntry, DidKeyUri}; 176 + 177 + pub struct PlcMonitor { 178 + pds_client: PdsClient, 179 + } 180 + 181 + impl PlcMonitor { 182 + pub fn new(pds_client: PdsClient) -> Self { 183 + Self { pds_client } 184 + } 185 + 186 + pub async fn check_for_changes(&self, did: &str) -> Result<Vec<UnauthorizedChange>, MonitorError> { 187 + // Step 1: Fetch current audit log 188 + let current_log_json = match self.pds_client.fetch_audit_log(did).await { 189 + Ok(json) => json, 190 + Err(e) => { 191 + tracing::warn!(did, error = %e, "Failed to fetch audit log, will retry next cycle"); 192 + return Ok(vec![]); 193 + } 194 + }; 195 + 196 + // Step 2: Parse current log 197 + let current_entries = match parse_audit_log(&current_log_json) { 198 + Ok(entries) => entries, 199 + Err(e) => { 200 + tracing::warn!(did, error = %e, "Failed to parse audit log"); 201 + return Ok(vec![]); 202 + } 203 + }; 204 + 205 + // Step 3: Load cached log 206 + let store = IdentityStore; 207 + let cached_entries = match store.get_plc_log(did) { 208 + Ok(Some(cached_json)) => match parse_audit_log(&cached_json) { 209 + Ok(entries) => entries, 210 + Err(e) => { 211 + tracing::warn!(did, error = %e, "Failed to parse cached audit log, treating as empty"); 212 + vec![] 213 + } 214 + }, 215 + Ok(None) => vec![], 216 + Err(e) => { 217 + return Err(MonitorError::IdentityStoreError { message: e.to_string() }); 218 + } 219 + }; 220 + 221 + // Step 4: Diff 222 + let new_entries = diff_audit_logs(&cached_entries, &current_entries); 223 + 224 + // Step 5: If no new entries, return 225 + if new_entries.is_empty() { 226 + return Ok(vec![]); 227 + } 228 + 229 + // Step 6: Get device key 230 + let device_key = store.get_or_create_device_key(did) 231 + .map_err(|e| MonitorError::IdentityStoreError { message: e.to_string() })?; 232 + let device_key_uri = DidKeyUri(device_key.key_id); 233 + 234 + // Step 7: Classify each new entry 235 + let mut unauthorized = Vec::new(); 236 + for entry in &new_entries { 237 + let op_json = serde_json::to_string(&entry.operation) 238 + .map_err(|e| MonitorError::ParseError { message: e.to_string() })?; 239 + 240 + // Try device key first 241 + if verify_plc_operation(&op_json, &[device_key_uri.clone()]).is_ok() { 242 + // Authorized — signed by our device key (AC6.1) 243 + continue; 244 + } 245 + 246 + // Unauthorized (AC6.2) — try to identify signing key 247 + let signing_key = identify_signing_key(&op_json, &current_entries, entry); 248 + 249 + unauthorized.push(UnauthorizedChange { 250 + cid: entry.cid.clone(), 251 + created_at: entry.created_at.clone(), 252 + signing_key, 253 + operation: entry.operation.clone(), 254 + }); 255 + } 256 + 257 + // Step 8: Update cached log 258 + store.store_plc_log(did, &current_log_json) 259 + .map_err(|e| MonitorError::IdentityStoreError { message: e.to_string() })?; 260 + 261 + Ok(unauthorized) 262 + } 263 + } 264 + 265 + /// Try each rotation key from the previous operation to identify who signed this entry. 266 + fn identify_signing_key( 267 + op_json: &str, 268 + all_entries: &[AuditEntry], 269 + target: &AuditEntry, 270 + ) -> Option<String> { 271 + // Find the entry just before target in the full log 272 + let prev_entry = all_entries.iter() 273 + .take_while(|e| e.cid != target.cid) 274 + .last()?; 275 + 276 + // Extract rotationKeys from previous operation 277 + let rotation_keys: Vec<String> = prev_entry.operation 278 + .get("rotationKeys") 279 + .and_then(|v| serde_json::from_value(v.clone()).ok()) 280 + .unwrap_or_default(); 281 + 282 + // Try each key individually 283 + for key in &rotation_keys { 284 + if verify_plc_operation(op_json, &[DidKeyUri(key.clone())]).is_ok() { 285 + return Some(key.clone()); 286 + } 287 + } 288 + None 289 + } 290 + ``` 291 + 292 + **Testing:** 293 + 294 + Tests must verify each AC listed above: 295 + - plc-key-management.AC6.1: Mock plc.directory returning a log with a new entry signed by the device key. Verify `check_for_changes` returns empty `Vec` and cached log is updated. 296 + - plc-key-management.AC6.2: Mock plc.directory returning a log with a new entry signed by a different key. Verify `check_for_changes` returns one `UnauthorizedChange` with the correct signing key. 297 + - plc-key-management.AC6.3: Verify `UnauthorizedChange.created_at` matches the operation's `createdAt` from the audit log (frontend uses this + 72h for deadline). 298 + - plc-key-management.AC6.7: Mock plc.directory returning a network error. Verify `check_for_changes` returns `Ok(vec![])` (no error, no alert). 299 + - plc-key-management.AC6.8: Start with no cached log and mock plc.directory returning an empty audit log. Verify `check_for_changes` returns `Ok(vec![])`. 300 + 301 + Follow existing test patterns in the codebase: 302 + - Use `#[tokio::test]` for async tests 303 + - Use `httpmock::MockServer` for plc.directory HTTP mocking 304 + - Use `PdsClient::new_for_test(mock_server.base_url())` to inject mock URL 305 + - In-memory Keychain mock is automatically active under `#[cfg(test)]` 306 + - Reference files for patterns: `pds_client.rs:705+` (HTTP mocking), `identity_store.rs:493+` (Keychain test helpers), `claim.rs:1101+` (error mapping tests) 307 + 308 + **Note on test data:** Tests will need realistic PLC operation JSON that can be verified by `verify_plc_operation`. The simplest approach is to use `crypto::build_did_plc_genesis_op` or `build_did_plc_genesis_op_with_external_signer` to generate a signed operation in the test, then wrap it in an `AuditEntry` structure. This ensures the signature is valid and verifiable. 309 + 310 + **Verification:** 311 + 312 + Run: `cd apps/identity-wallet/src-tauri && cargo test plc_monitor` 313 + Expected: All tests pass 314 + 315 + **Commit:** `feat(identity-wallet): implement PlcMonitor::check_for_changes` 316 + 317 + <!-- END_TASK_2 --> 318 + 319 + <!-- START_TASK_3 --> 320 + ### Task 3: Implement PlcMonitor::check_all 321 + 322 + **Verifies:** plc-key-management.AC6.1, plc-key-management.AC6.2 (multi-identity variant) 323 + 324 + **Files:** 325 + - Modify: `apps/identity-wallet/src-tauri/src/plc_monitor.rs` 326 + 327 + **Implementation:** 328 + 329 + Add `check_all` method to `PlcMonitor`: 330 + 331 + ```rust 332 + impl PlcMonitor { 333 + // ... existing methods ... 334 + 335 + pub async fn check_all(&self) -> Result<Vec<IdentityStatus>, MonitorError> { 336 + let store = IdentityStore; 337 + let dids = store.list_identities() 338 + .map_err(|e| MonitorError::IdentityStoreError { message: e.to_string() })?; 339 + 340 + let mut statuses = Vec::new(); 341 + for did in &dids { 342 + let unauthorized = self.check_for_changes(did).await?; 343 + statuses.push(IdentityStatus { 344 + did: did.clone(), 345 + alert_count: unauthorized.len(), 346 + unauthorized_changes: unauthorized, 347 + }); 348 + } 349 + Ok(statuses) 350 + } 351 + } 352 + ``` 353 + 354 + Note: Sequential iteration over DIDs is intentional for v1 — avoids concurrent Keychain access issues. Concurrent fetches can be added later if monitoring many identities becomes slow. 355 + 356 + **Testing:** 357 + 358 + Tests must verify: 359 + - plc-key-management.AC6.1 (multi-identity): Register two DIDs, mock plc.directory for both. One has a new authorized op, one has no changes. Verify `check_all` returns two `IdentityStatus` entries both with `alert_count: 0`. 360 + - plc-key-management.AC6.2 (multi-identity): Register two DIDs, one with an unauthorized op. Verify `check_all` returns correct alert counts per identity. 361 + 362 + Follow same patterns as Task 2 tests but with multiple mock DID endpoints. 363 + 364 + **Verification:** 365 + 366 + Run: `cd apps/identity-wallet/src-tauri && cargo test plc_monitor` 367 + Expected: All tests pass 368 + 369 + **Commit:** `feat(identity-wallet): implement PlcMonitor::check_all` 370 + 371 + <!-- END_TASK_3 --> 372 + 373 + <!-- START_TASK_4 --> 374 + ### Task 4: Register check_identity_status Tauri IPC command 375 + 376 + **Verifies:** None (infrastructure — wires PlcMonitor to frontend IPC) 377 + 378 + **Files:** 379 + - Modify: `apps/identity-wallet/src-tauri/src/plc_monitor.rs` (add Tauri command) 380 + - Modify: `apps/identity-wallet/src-tauri/src/lib.rs` (register command in `invoke_handler`) 381 + 382 + **Implementation:** 383 + 384 + Add a Tauri IPC command in `plc_monitor.rs`: 385 + 386 + ```rust 387 + /// Tauri IPC command: check all managed identities for unauthorized PLC operations. 388 + /// Returns a list of IdentityStatus, one per managed DID. 389 + #[tauri::command] 390 + pub async fn check_identity_status( 391 + state: tauri::State<'_, crate::oauth::AppState>, 392 + ) -> Result<Vec<IdentityStatus>, MonitorError> { 393 + let monitor = PlcMonitor::new(state.pds_client().clone()); 394 + monitor.check_all().await 395 + } 396 + ``` 397 + 398 + In `lib.rs`, add `plc_monitor::check_identity_status` to the `invoke_handler` builder alongside existing commands: 399 + 400 + ```rust 401 + .invoke_handler(tauri::generate_handler![ 402 + // ... existing commands ... 403 + plc_monitor::check_identity_status, 404 + ]) 405 + ``` 406 + 407 + **Verification:** 408 + 409 + Run: `cd apps/identity-wallet/src-tauri && cargo check` 410 + Expected: Compiles without errors (Tauri command registration is compile-time verified) 411 + 412 + **Commit:** `feat(identity-wallet): register check_identity_status IPC command` 413 + 414 + <!-- END_TASK_4 --> 415 + <!-- END_SUBCOMPONENT_A -->
+206
docs/implementation-plans/2026-03-29-plc-monitoring-alerting/phase_02.md
··· 1 + # PLC Monitoring & Alerting Implementation Plan — Phase 2: Monitor Lifecycle 2 + 3 + **Goal:** Wire the PlcMonitor into the app lifecycle: a 15-minute polling timer while the app is open, an immediate check when the app returns to foreground, and event emission so the frontend can react to alerts. 4 + 5 + **Architecture:** A background `tokio::time::interval` spawned in the Tauri `.setup()` closure runs `PlcMonitor::check_all()` every 15 minutes. On each cycle, if unauthorized changes are detected, a `"plc_alert"` Tauri event is emitted to the frontend. App foreground detection uses the browser's `visibilitychange` event in the WKWebView (no native plugin needed) — the frontend calls `check_identity_status` IPC command when the app becomes visible. iOS background fetch (BGTaskScheduler) is deferred as best-effort future work. 6 + 7 + **Tech Stack:** Rust (tokio::time, tauri::async_runtime), Svelte 5 (frontend visibility listener) 8 + 9 + **Scope:** 3 phases from design Phase 6. This is phase 2 of 3. 10 + 11 + **Codebase verified:** 2026-03-29 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + This phase implements and tests: 18 + 19 + ### plc-key-management.AC6: PLC monitoring and alerting 20 + - **plc-key-management.AC6.6 Success:** Monitor runs on app foreground and on a 15-minute timer while app is open 21 + 22 + --- 23 + 24 + ## Codebase Verification Findings 25 + 26 + - ✓ `lib.rs:754-818` — `run()` function with `.setup()` closure; existing background task pattern at lines 787-790 (`tauri::async_runtime::spawn` with `app.handle().clone()`) 27 + - ✓ `AppState::new()` at `oauth.rs` — manages `PdsClient` (eager), accessed via `state.pds_client()` 28 + - ✓ `AppState` is registered via `.manage(oauth::AppState::new())` at line 757 29 + - ✓ Existing `handle.emit()` pattern for Tauri events (e.g., `"auth_ready"`, `"pds_auth_ready"`) 30 + - ✓ No existing lifecycle plugin — `tauri-plugin-deep-link` and `tauri-plugin-opener` only 31 + - ✓ `tokio` available with `macros` and `rt` features in dev-dependencies; runtime features available via Tauri 32 + - ✓ `visibilitychange` browser event fires reliably in WKWebView for iOS foreground/background transitions 33 + 34 + ## External Dependency Findings 35 + 36 + - ✓ `tokio::time::interval` with `MissedTickBehavior::Delay` prevents burst of catch-up ticks after iOS app suspension — timer pauses while suspended, resumes normally 37 + - ✓ No `tauri-plugin-app-events` needed — browser `visibilitychange` event handles foreground detection from the frontend side 38 + - ✓ iOS `BGTaskScheduler` for background fetch requires custom Swift plugin — deferred to future work (design marks this as "best-effort, OS-throttled") 39 + 40 + --- 41 + 42 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 43 + 44 + <!-- START_TASK_1 --> 45 + ### Task 1: Spawn 15-minute monitoring timer in Tauri setup 46 + 47 + **Verifies:** plc-key-management.AC6.6 48 + 49 + **Files:** 50 + - Modify: `apps/identity-wallet/src-tauri/src/plc_monitor.rs` (add `run_monitoring_loop` function) 51 + - Modify: `apps/identity-wallet/src-tauri/src/lib.rs` (spawn timer in `.setup()` closure) 52 + 53 + **Implementation:** 54 + 55 + Add a public function to `plc_monitor.rs` that runs the monitoring loop. This function is spawned once during app setup and runs for the lifetime of the app. 56 + 57 + ```rust 58 + use std::time::Duration; 59 + use tokio::time::{interval, MissedTickBehavior}; 60 + 61 + const MONITOR_INTERVAL_SECS: u64 = 15 * 60; // 15 minutes 62 + 63 + /// Run a single monitoring cycle. Extracted from the loop for testability. 64 + /// Returns the list of identity statuses with any alerts. 65 + pub async fn run_monitoring_cycle(monitor: &PlcMonitor) -> Vec<IdentityStatus> { 66 + match monitor.check_all().await { 67 + Ok(statuses) => statuses, 68 + Err(e) => { 69 + tracing::warn!(error = %e, "Monitoring cycle check_all failed"); 70 + vec![] 71 + } 72 + } 73 + } 74 + 75 + /// Run the PLC monitoring loop. Spawned once during app setup. 76 + /// Checks all managed identities every 15 minutes and emits "plc_alert" 77 + /// events to the frontend when unauthorized changes are detected. 78 + pub async fn run_monitoring_loop(app_handle: tauri::AppHandle) { 79 + let mut interval = interval(Duration::from_secs(MONITOR_INTERVAL_SECS)); 80 + // Don't burst-fire missed ticks after iOS suspension 81 + interval.set_missed_tick_behavior(MissedTickBehavior::Delay); 82 + // Skip the first immediate tick — let the app finish initializing 83 + interval.tick().await; 84 + 85 + loop { 86 + interval.tick().await; 87 + 88 + let state = app_handle.state::<crate::oauth::AppState>(); 89 + let monitor = PlcMonitor::new(state.pds_client().clone()); 90 + let statuses = run_monitoring_cycle(&monitor).await; 91 + 92 + let has_alerts = statuses.iter().any(|s| s.alert_count > 0); 93 + if has_alerts { 94 + if let Err(e) = app_handle.emit("plc_alert", &statuses) { 95 + tracing::warn!(error = %e, "Failed to emit plc_alert event"); 96 + } 97 + } 98 + } 99 + } 100 + ``` 101 + 102 + Note: `run_monitoring_cycle` is independently testable — it takes a `&PlcMonitor` (which can be constructed with `PdsClient::new_for_test()`) and returns `Vec<IdentityStatus>` without requiring a Tauri app handle. Tests for the cycle logic can use the same `httpmock` patterns as Phase 1. 103 + 104 + In `lib.rs`, after the existing session restore spawn (around line 791), add: 105 + 106 + ```rust 107 + // Start PLC monitoring timer (15-minute interval) 108 + let monitor_handle = app.handle().clone(); 109 + tauri::async_runtime::spawn(plc_monitor::run_monitoring_loop(monitor_handle)); 110 + ``` 111 + 112 + **Verification:** 113 + 114 + Run: `cd apps/identity-wallet/src-tauri && cargo check` 115 + Expected: Compiles without errors 116 + 117 + Run: `cd apps/identity-wallet/src-tauri && cargo test plc_monitor` 118 + Expected: Existing Phase 1 tests still pass 119 + 120 + **Commit:** `feat(identity-wallet): spawn 15-minute PLC monitoring timer` 121 + 122 + <!-- END_TASK_1 --> 123 + 124 + <!-- START_TASK_2 --> 125 + ### Task 2: Add frontend visibility-change handler for app foreground check 126 + 127 + **Verifies:** plc-key-management.AC6.6 128 + 129 + **Files:** 130 + - Modify: `apps/identity-wallet/src/routes/+page.svelte` (add visibility-change listener) 131 + - Modify: `apps/identity-wallet/src/lib/ipc.ts` (add `checkIdentityStatus` IPC wrapper) 132 + 133 + **Implementation:** 134 + 135 + First, add the IPC wrapper to `ipc.ts`. Follow the existing pattern of typed wrappers (e.g., `listIdentities`, `getStoredDidDoc`): 136 + 137 + ```typescript 138 + import { invoke } from '@tauri-apps/api/core'; 139 + 140 + // Add alongside existing type exports: 141 + export interface UnauthorizedChange { 142 + cid: string; 143 + createdAt: string; 144 + signingKey: string | null; 145 + operation: unknown; 146 + } 147 + 148 + export interface IdentityStatus { 149 + did: string; 150 + alertCount: number; 151 + unauthorizedChanges: UnauthorizedChange[]; 152 + } 153 + 154 + // Add alongside existing function exports: 155 + export async function checkIdentityStatus(): Promise<IdentityStatus[]> { 156 + return invoke<IdentityStatus[]>('check_identity_status'); 157 + } 158 + ``` 159 + 160 + In `+page.svelte`, add a visibility-change listener. This should be added in the root page component since it needs to run regardless of which screen is active. Add it alongside the existing `onMount` logic: 161 + 162 + ```svelte 163 + <script lang="ts"> 164 + import { onMount, onDestroy } from 'svelte'; 165 + import { checkIdentityStatus } from '$lib/ipc'; 166 + 167 + // ... existing state and logic ... 168 + 169 + // PLC monitoring: check on app foreground 170 + function handleVisibilityChange() { 171 + if (document.visibilityState === 'visible' && step === 'home') { 172 + checkIdentityStatus().catch((e) => { 173 + console.warn('PLC status check failed:', e); 174 + }); 175 + } 176 + } 177 + 178 + onMount(() => { 179 + document.addEventListener('visibilitychange', handleVisibilityChange); 180 + // ... existing onMount logic ... 181 + }); 182 + 183 + onDestroy(() => { 184 + document.removeEventListener('visibilitychange', handleVisibilityChange); 185 + }); 186 + </script> 187 + ``` 188 + 189 + The `step === 'home'` guard ensures we only check when the user is on the home screen (not mid-onboarding or mid-import flow). 190 + 191 + **Testing:** 192 + 193 + This task tests the IPC type contract and visibility-change wiring. The behavior verification is: 194 + - plc-key-management.AC6.6: Monitor runs on app foreground — `visibilitychange` triggers `checkIdentityStatus()` when app becomes visible and user is on home screen. 195 + 196 + Testing approach: This is primarily infrastructure/wiring code. The IPC command was compile-time verified in Phase 1. The frontend listener is a thin wrapper over `visibilitychange` → `invoke()`. No dedicated unit test needed — verified by the Phase 3 frontend integration. 197 + 198 + **Verification:** 199 + 200 + Run: `cd apps/identity-wallet && pnpm check` 201 + Expected: Svelte type checking passes (confirms IPC types match) 202 + 203 + **Commit:** `feat(identity-wallet): add foreground PLC check via visibility-change` 204 + 205 + <!-- END_TASK_2 --> 206 + <!-- END_SUBCOMPONENT_A -->
+465
docs/implementation-plans/2026-03-29-plc-monitoring-alerting/phase_03.md
··· 1 + # PLC Monitoring & Alerting Implementation Plan — Phase 3: Frontend Alerts 2 + 3 + **Goal:** Display alert badges on identity cards when unauthorized PLC operations are detected, and provide an alert detail screen showing the signing key, timestamp, and recovery deadline countdown. 4 + 5 + **Architecture:** `IdentityListHome.svelte` calls `checkIdentityStatus()` on mount (alongside existing identity loading) and renders a red alert badge on cards with `alertCount > 0`. Tapping an alerted card navigates to `AlertDetailScreen.svelte` which shows each unauthorized change with its signing key, timestamp, and a real-time countdown to the 72-hour recovery deadline. The frontend also listens for `"plc_alert"` Tauri events from the background monitoring timer to update badge counts without requiring user interaction. 6 + 7 + **Tech Stack:** Svelte 5 (runes: $state, $derived, $props), SvelteKit 2, TypeScript, @tauri-apps/api (core + event) 8 + 9 + **Scope:** 3 phases from design Phase 6. This is phase 3 of 3. 10 + 11 + **Codebase verified:** 2026-03-29 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + This phase implements and tests: 18 + 19 + ### plc-key-management.AC6: PLC monitoring and alerting 20 + - **plc-key-management.AC6.4 Success:** Home screen shows red alert badge on identity cards with `alertCount > 0` 21 + - **plc-key-management.AC6.5 Success:** Alert detail screen shows signing key, timestamp, and recovery deadline countdown 22 + 23 + --- 24 + 25 + ## Codebase Verification Findings 26 + 27 + - ✓ `IdentityListHome.svelte` (375 lines) — multi-identity card list with existing badge pattern for rotation key status (`.badge--root`, `.badge--not-root`, `.badge--unknown`) 28 + - ✓ Card rendering at lines 124-151 — flexbox horizontal layout: avatar + info + badge 29 + - ✓ Existing badge CSS: `.badge { flex; gap; padding; border-radius: 6px; font-size: 0.75rem; font-weight: 600 }` with `.badge-dot { 6px circle }` 30 + - ✓ Color palette: error red = `#ef4444`, error bg = (no existing red badge — will add `.badge--alert` with `#fef2f2` bg, `#ef4444` dot, `#991b1b` text) 31 + - ✓ Props: `{ onadd, onselect }` via `$props()` 32 + - ✓ Data loading on mount: `listIdentities()` → parallel `getStoredDidDoc()` + `getDeviceKeyId()` per DID 33 + - ✓ State machine in `+page.svelte` — `step` variable, `goTo(step)` function 34 + - ✓ Existing steps include `home`, `identity_detail`, `did_document`, `recovery_info` 35 + - ✓ `DIDDocumentScreen.svelte` pattern: receives `{ didDoc, onback }` props 36 + - ✓ `ipc.ts` (489 lines) — all IPC wrappers exported here; `checkIdentityStatus` to be added in Phase 2 37 + - ✓ Svelte 5 runes: `$state`, `$derived`, `$props()`, `onMount()` (no `$effect`) 38 + - ✓ Scoped CSS, hand-written (no Tailwind), consistent spacing (rem units) 39 + - ✓ `@tauri-apps/api/event` — `listen()` function available for Tauri event subscription 40 + 41 + --- 42 + 43 + <!-- START_SUBCOMPONENT_A (tasks 1-4) --> 44 + 45 + <!-- START_TASK_1 --> 46 + ### Task 1: Add alert badge to IdentityListHome identity cards 47 + 48 + **Verifies:** plc-key-management.AC6.4 49 + 50 + **Files:** 51 + - Modify: `apps/identity-wallet/src/lib/components/home/IdentityListHome.svelte` 52 + 53 + **Implementation:** 54 + 55 + Extend `IdentityListHome` to fetch alert status and display red alert badges on identity cards. 56 + 57 + Changes to the component: 58 + 59 + 1. **Import `checkIdentityStatus`** from `$lib/ipc` (added in Phase 2). 60 + 61 + 2. **Add alert state** alongside existing `identities` state. Use a single `alertData` map (derive counts from array length to avoid redundant state): 62 + ```typescript 63 + let alertData = $state<Map<string, UnauthorizedChange[]>>(new Map()); 64 + ``` 65 + 66 + 3. **Fetch alert status on mount** — add a call to `checkIdentityStatus()` after the existing identity loading. This is a separate async call that runs in parallel; alert badge rendering is additive (cards render fine without alert data). 67 + ```typescript 68 + // In onMount, after existing identity loading: 69 + checkIdentityStatus() 70 + .then((statuses) => { 71 + const data = new Map<string, UnauthorizedChange[]>(); 72 + for (const s of statuses) { 73 + if (s.alertCount > 0) data.set(s.did, s.unauthorizedChanges); 74 + } 75 + alertData = data; 76 + }) 77 + .catch((e) => console.warn('Alert check failed:', e)); 78 + ``` 79 + 80 + 4. **Listen for `plc_alert` events** from the background monitoring timer (Phase 2). Subscribe in `onMount`, unsubscribe in `onDestroy`: 81 + ```typescript 82 + import { listen, type UnlistenFn } from '@tauri-apps/api/event'; 83 + import { onDestroy } from 'svelte'; 84 + 85 + let unlisten: UnlistenFn | null = null; 86 + 87 + onMount(async () => { 88 + // ... existing loading logic ... 89 + 90 + unlisten = await listen<IdentityStatus[]>('plc_alert', (event) => { 91 + const data = new Map<string, UnauthorizedChange[]>(); 92 + for (const s of event.payload) { 93 + if (s.alertCount > 0) data.set(s.did, s.unauthorizedChanges); 94 + } 95 + alertData = data; 96 + }); 97 + }); 98 + 99 + onDestroy(() => { 100 + unlisten?.(); 101 + }); 102 + ``` 103 + 104 + 5. **Render alert badge** on each card, alongside the existing rotation key badge. Add a conditional alert badge that appears ABOVE the rotation key badge when the DID has unauthorized changes (derive count from `alertData.get(did)?.length`): 105 + ```svelte 106 + <div class="card-badge"> 107 + {#if alertData.get(card.did)?.length} 108 + <span class="badge badge--alert"> 109 + <span class="badge-dot"></span> 110 + {alertData.get(card.did)?.length} {alertData.get(card.did)?.length === 1 ? 'Alert' : 'Alerts'} 111 + </span> 112 + {/if} 113 + <span 114 + class="badge" 115 + class:badge--root={card.deviceKeyIsRoot === true} 116 + class:badge--not-root={card.deviceKeyIsRoot === false} 117 + class:badge--unknown={card.deviceKeyIsRoot === null} 118 + > 119 + <span class="badge-dot"></span> 120 + {getBadgeLabel(card.deviceKeyIsRoot)} 121 + </span> 122 + </div> 123 + ``` 124 + 125 + 6. **Add CSS for alert badge** — follows existing badge pattern with red color scheme: 126 + ```css 127 + .badge--alert { 128 + background: #fef2f2; 129 + color: #991b1b; 130 + } 131 + 132 + .badge--alert .badge-dot { 133 + background: #ef4444; 134 + } 135 + ``` 136 + 137 + 7. **Update `onselect` callback** to also pass alert data. Modify the card click handler to include alert info so the parent can navigate to alert detail when appropriate: 138 + - The parent (`+page.svelte`) will need the alert data to decide whether to show the alert detail screen 139 + - Add alert statuses to the component's exported data by expanding the `onselect` callback or adding a separate `onalert` callback 140 + 141 + The cleaner approach: add an `onalert` prop callback: 142 + ```typescript 143 + let { onadd, onselect, onalert } = $props<{ 144 + onadd: () => void; 145 + onselect: (did: string, didDoc: Record<string, unknown>) => void; 146 + onalert?: (did: string, changes: UnauthorizedChange[]) => void; 147 + }>(); 148 + ``` 149 + 150 + The `alertData` map (declared in step 2) already holds the full `UnauthorizedChange[]` per DID for navigation. 151 + 152 + Update the badge to be clickable when alerts exist — tapping the alert badge calls `onalert`: 153 + ```svelte 154 + {#if alertData.get(card.did)?.length} 155 + <button 156 + class="badge badge--alert" 157 + onclick={(e) => { e.stopPropagation(); onalert?.(card.did, alertData.get(card.did) ?? []); }} 158 + > 159 + <span class="badge-dot"></span> 160 + {alertData.get(card.did)?.length} {alertData.get(card.did)?.length === 1 ? 'Alert' : 'Alerts'} 161 + </button> 162 + {/if} 163 + ``` 164 + 165 + Note: Svelte 5 does not support pipe modifiers on `onclick`. Use inline `e.stopPropagation()` to prevent the card's `onselect` handler from also firing. 166 + 167 + **Testing:** 168 + 169 + This is frontend UI code. Verify visually and via type checking: 170 + - plc-key-management.AC6.4: When `checkIdentityStatus` returns identities with `alertCount > 0`, a red badge with the count appears on those cards. Cards with `alertCount: 0` show no alert badge. 171 + 172 + **Verification:** 173 + 174 + Run: `cd apps/identity-wallet && pnpm check` 175 + Expected: Svelte type checking passes 176 + 177 + **Commit:** `feat(identity-wallet): add alert badge to identity cards` 178 + 179 + <!-- END_TASK_1 --> 180 + 181 + <!-- START_TASK_2 --> 182 + ### Task 2: Create AlertDetailScreen component 183 + 184 + **Verifies:** plc-key-management.AC6.5 185 + 186 + **Files:** 187 + - Create: `apps/identity-wallet/src/lib/components/home/AlertDetailScreen.svelte` 188 + 189 + **Implementation:** 190 + 191 + Create a new Svelte 5 component following the pattern of `DIDDocumentScreen.svelte` and `RecoveryInfoScreen.svelte`. 192 + 193 + Props: 194 + ```typescript 195 + let { did, changes, onback } = $props<{ 196 + did: string; 197 + changes: UnauthorizedChange[]; 198 + onback: () => void; 199 + }>(); 200 + ``` 201 + 202 + The component displays: 203 + 1. **Header** with back button and title ("Security Alerts") 204 + 2. **Identity** — the DID this alert is for (truncated) 205 + 3. **Alert cards** — one per `UnauthorizedChange`, each showing: 206 + - **Signing key**: `change.signingKey` displayed as a truncated did:key URI, or "Unknown key" if null 207 + - **Timestamp**: `change.createdAt` formatted as a human-readable date/time 208 + - **Recovery deadline countdown**: computed from `change.createdAt + 72 hours` 209 + - Green if >24h remaining 210 + - Amber if 4-24h remaining 211 + - Red if <4h remaining or expired 212 + - **"Review & Override" button** (placeholder — wired in Phase 7 recovery override; disabled for now) 213 + 214 + Recovery deadline computation (in component): 215 + ```typescript 216 + const RECOVERY_WINDOW_MS = 72 * 60 * 60 * 1000; 217 + 218 + function getDeadline(createdAt: string): Date { 219 + return new Date(new Date(createdAt).getTime() + RECOVERY_WINDOW_MS); 220 + } 221 + 222 + function formatCountdown(deadline: Date): string { 223 + const remaining = deadline.getTime() - Date.now(); 224 + if (remaining <= 0) return 'Expired'; 225 + const hours = Math.floor(remaining / (1000 * 60 * 60)); 226 + const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60)); 227 + return `${hours}h ${minutes}m remaining`; 228 + } 229 + ``` 230 + 231 + For a live countdown, use `setInterval` (started in `onMount`, cleared in `onDestroy`) to update a `$state` variable every 60 seconds: 232 + ```typescript 233 + let now = $state(Date.now()); 234 + let timer: ReturnType<typeof setInterval> | null = null; 235 + 236 + onMount(() => { 237 + timer = setInterval(() => { now = Date.now(); }, 60_000); 238 + }); 239 + 240 + onDestroy(() => { 241 + if (timer) clearInterval(timer); 242 + }); 243 + ``` 244 + 245 + Then use `$derived` for each change's countdown: 246 + ```svelte 247 + {#each changes as change (change.cid)} 248 + {@const deadline = getDeadline(change.createdAt)} 249 + {@const remaining = deadline.getTime() - now} 250 + {@const urgency = remaining <= 0 ? 'expired' : remaining < 4 * 3600000 ? 'critical' : remaining < 24 * 3600000 ? 'warning' : 'safe'} 251 + 252 + <div class="alert-card"> 253 + <div class="alert-header"> 254 + <span class="alert-urgency alert-urgency--{urgency}"> 255 + <span class="badge-dot"></span> 256 + {remaining <= 0 ? 'Expired' : `${Math.floor(remaining / 3600000)}h ${Math.floor((remaining % 3600000) / 60000)}m remaining`} 257 + </span> 258 + </div> 259 + 260 + <div class="alert-field"> 261 + <span class="alert-label">Signing Key</span> 262 + <span class="alert-value monospace">{change.signingKey ?? 'Unknown key'}</span> 263 + </div> 264 + 265 + <div class="alert-field"> 266 + <span class="alert-label">Detected</span> 267 + <span class="alert-value">{new Date(change.createdAt).toLocaleString()}</span> 268 + </div> 269 + 270 + <div class="alert-field"> 271 + <span class="alert-label">Recovery Deadline</span> 272 + <span class="alert-value">{deadline.toLocaleString()}</span> 273 + </div> 274 + 275 + <button class="action-button" disabled> 276 + Review & Override 277 + </button> 278 + </div> 279 + {/each} 280 + ``` 281 + 282 + Styling follows existing patterns: 283 + - `.alert-card`: same card styling as identity cards (12px radius, 1.25rem padding, border) 284 + - `.alert-label`: 0.75rem, 600 weight, uppercase, letter-spacing 0.04em (matches existing label style) 285 + - `.alert-value`: 1rem, `#374151` color 286 + - `.monospace`: font-family monospace, 0.8rem, word-break break-all 287 + - `.alert-urgency--safe`: green (`#dcfce7` bg, `#16a34a` dot) 288 + - `.alert-urgency--warning`: amber (`#fef3c7` bg, `#f59e0b` dot) 289 + - `.alert-urgency--critical` / `.alert-urgency--expired`: red (`#fef2f2` bg, `#ef4444` dot) 290 + - `.action-button`: `#007aff` blue, full-width, 12px radius, 0.9rem padding — disabled state has `opacity: 0.5, cursor: not-allowed` 291 + - Back button: same pattern as `DIDDocumentScreen` (← arrow + "Back" text) 292 + 293 + **Testing:** 294 + 295 + This is frontend UI code with time-based rendering logic: 296 + - plc-key-management.AC6.5: Alert detail screen shows signing key, timestamp, and recovery deadline countdown. Visual verification — component renders all fields from `UnauthorizedChange` data. 297 + 298 + **Verification:** 299 + 300 + Run: `cd apps/identity-wallet && pnpm check` 301 + Expected: Svelte type checking passes 302 + 303 + **Commit:** `feat(identity-wallet): create AlertDetailScreen component` 304 + 305 + <!-- END_TASK_2 --> 306 + 307 + <!-- START_TASK_3 --> 308 + ### Task 3: Wire AlertDetailScreen into page state machine 309 + 310 + **Verifies:** plc-key-management.AC6.4, plc-key-management.AC6.5 311 + 312 + **Files:** 313 + - Modify: `apps/identity-wallet/src/routes/+page.svelte` 314 + 315 + **Implementation:** 316 + 317 + 1. **Add `'alert_detail'` to `OnboardingStep` type** (around line 38-62): 318 + ```typescript 319 + type OnboardingStep = /* existing steps */ | 'alert_detail'; 320 + ``` 321 + 322 + 2. **Add alert state variables:** 323 + ```typescript 324 + let selectedAlertDid = $state<string | null>(null); 325 + let selectedAlertChanges = $state<UnauthorizedChange[]>([]); 326 + ``` 327 + 328 + 3. **Import AlertDetailScreen** and the `UnauthorizedChange` type: 329 + ```typescript 330 + import AlertDetailScreen from '$lib/components/home/AlertDetailScreen.svelte'; 331 + import type { UnauthorizedChange } from '$lib/ipc'; 332 + ``` 333 + 334 + 4. **Update `IdentityListHome` usage** to include the `onalert` callback (around lines 310-320): 335 + ```svelte 336 + {:else if step === 'home'} 337 + <IdentityListHome 338 + onadd={() => goTo('mode_select')} 339 + onselect={(_did, didDoc) => { 340 + selectedDidDoc = didDoc; 341 + goTo('identity_detail'); 342 + }} 343 + onalert={(did, changes) => { 344 + selectedAlertDid = did; 345 + selectedAlertChanges = changes; 346 + goTo('alert_detail'); 347 + }} 348 + /> 349 + ``` 350 + 351 + 5. **Add `alert_detail` step rendering** (after the `identity_detail` block): 352 + ```svelte 353 + {:else if step === 'alert_detail'} 354 + <AlertDetailScreen 355 + did={selectedAlertDid ?? ''} 356 + changes={selectedAlertChanges} 357 + onback={() => goTo('home')} 358 + /> 359 + ``` 360 + 361 + **Testing:** 362 + 363 + Integration wiring — verified via type checking and the end-to-end flow: 364 + - plc-key-management.AC6.4: Tapping alert badge navigates to alert detail screen 365 + - plc-key-management.AC6.5: AlertDetailScreen renders with correct data from parent state 366 + 367 + **Verification:** 368 + 369 + Run: `cd apps/identity-wallet && pnpm check` 370 + Expected: Svelte type checking passes 371 + 372 + **Commit:** `feat(identity-wallet): wire AlertDetailScreen into page state machine` 373 + 374 + <!-- END_TASK_3 --> 375 + 376 + <!-- START_TASK_4 --> 377 + ### Task 4: Extract and test deadline computation utilities 378 + 379 + **Verifies:** plc-key-management.AC6.3, plc-key-management.AC6.5 380 + 381 + **Files:** 382 + - Create: `apps/identity-wallet/src/lib/utils/deadline.ts` 383 + - Create: `apps/identity-wallet/src/lib/utils/deadline.test.ts` 384 + - Modify: `apps/identity-wallet/src/lib/components/home/AlertDetailScreen.svelte` (import from utils instead of inline) 385 + 386 + **Implementation:** 387 + 388 + Extract the deadline computation functions from `AlertDetailScreen.svelte` into a testable utility module: 389 + 390 + ```typescript 391 + // $lib/utils/deadline.ts 392 + 393 + export const RECOVERY_WINDOW_MS = 72 * 60 * 60 * 1000; // 72 hours 394 + 395 + export function getDeadline(createdAt: string): Date { 396 + return new Date(new Date(createdAt).getTime() + RECOVERY_WINDOW_MS); 397 + } 398 + 399 + export type Urgency = 'safe' | 'warning' | 'critical' | 'expired'; 400 + 401 + export function getUrgency(deadline: Date, now: number = Date.now()): Urgency { 402 + const remaining = deadline.getTime() - now; 403 + if (remaining <= 0) return 'expired'; 404 + if (remaining < 4 * 60 * 60 * 1000) return 'critical'; 405 + if (remaining < 24 * 60 * 60 * 1000) return 'warning'; 406 + return 'safe'; 407 + } 408 + 409 + export function formatCountdown(deadline: Date, now: number = Date.now()): string { 410 + const remaining = deadline.getTime() - now; 411 + if (remaining <= 0) return 'Expired'; 412 + const hours = Math.floor(remaining / (1000 * 60 * 60)); 413 + const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60)); 414 + return `${hours}h ${minutes}m remaining`; 415 + } 416 + ``` 417 + 418 + Update `AlertDetailScreen.svelte` to import from `$lib/utils/deadline` instead of defining inline. 419 + 420 + **Testing:** 421 + 422 + Write tests in `deadline.test.ts` covering: 423 + - plc-key-management.AC6.3: `getDeadline('2026-03-29T12:00:00.000Z')` returns `Date` exactly 72h later (`2026-04-01T12:00:00.000Z`) 424 + - plc-key-management.AC6.5: Urgency thresholds: 425 + - `getUrgency(deadline, deadline - 48h)` → `'safe'` (48h remaining, >24h) 426 + - `getUrgency(deadline, deadline - 12h)` → `'warning'` (12h remaining, 4-24h) 427 + - `getUrgency(deadline, deadline - 2h)` → `'critical'` (2h remaining, <4h) 428 + - `getUrgency(deadline, deadline + 1h)` → `'expired'` (1h past) 429 + - `getUrgency(deadline, deadline)` → `'expired'` (exactly at deadline) 430 + - `formatCountdown` edge cases: 431 + - Exactly 72h remaining → `'72h 0m remaining'` 432 + - 0h remaining → `'Expired'` 433 + - 23h 59m remaining → `'23h 59m remaining'` 434 + 435 + Follow the project's test runner. Check if `vitest` is configured in `apps/identity-wallet/package.json` or if tests run via another mechanism. 436 + 437 + **Verification:** 438 + 439 + Run: `cd apps/identity-wallet && pnpm test` (or the project's test command) 440 + Expected: All deadline utility tests pass 441 + 442 + **Commit:** `feat(identity-wallet): extract and test deadline computation utilities` 443 + 444 + <!-- END_TASK_4 --> 445 + <!-- END_SUBCOMPONENT_A --> 446 + 447 + --- 448 + 449 + ## Identity-Wallet CLAUDE.md Updates 450 + 451 + After completing Phase 3, the following updates should be made to `apps/identity-wallet/CLAUDE.md`: 452 + 453 + **Exposes section additions:** 454 + - Add `checkIdentityStatus()` to the `ipc.ts` exports list 455 + - Add `UnauthorizedChange` and `IdentityStatus` to the exported types list 456 + - Add `AlertDetailScreen.svelte` to the home components list 457 + - Add `plc_monitor::check_identity_status` to the Rust backend commands list 458 + - Add `plc_monitor.rs` to the Rust backend module descriptions 459 + - Add `'alert_detail'` to the OnboardingStep type and state machine documentation 460 + 461 + **Guarantees section additions:** 462 + - `MonitorError` variants serialize as `{ code: "SCREAMING_SNAKE_CASE" }` matching existing error pattern 463 + - `check_identity_status` always returns Ok for individual identity errors — per-identity network failures are logged and produce `alert_count: 0` (graceful degradation matching `load_home_data` pattern) 464 + - `run_monitoring_loop` uses `MissedTickBehavior::Delay` — no burst of catch-up ticks after iOS suspension 465 + - `UnauthorizedChange.created_at` is a raw ISO 8601 string; frontend computes 72h recovery deadline
+182
docs/implementation-plans/2026-03-29-plc-monitoring-alerting/test-requirements.md
··· 1 + # Test Requirements: PLC Monitoring & Alerting (AC6) 2 + 3 + **Design plan:** `docs/design-plans/2026-03-28-plc-key-management.md` (AC6, lines 73-81) 4 + **Implementation plans:** `docs/implementation-plans/2026-03-29-plc-monitoring-alerting/` (phases 1-3) 5 + **Date:** 2026-03-29 6 + 7 + --- 8 + 9 + ## AC6.1: Authorized operation detection 10 + 11 + **Criterion:** Monitor detects a new PLC operation signed by the device key and updates cached log without alerting. 12 + 13 + | Field | Value | 14 + |-------|-------| 15 + | Verification | Automated test | 16 + | Test type | Integration | 17 + | Test file | `apps/identity-wallet/src-tauri/src/plc_monitor.rs` (`#[cfg(test)]` module) | 18 + | Implementation phase | Phase 1, Task 2 | 19 + 20 + **Test approach:** Construct a valid PLC genesis operation signed by the device key using `crypto::build_did_plc_genesis_op`. Mock plc.directory via `httpmock::MockServer` to return an audit log containing this operation. Call `PlcMonitor::check_for_changes(did)`. Assert: (1) return value is `Ok(vec![])` (no unauthorized changes), (2) `IdentityStore::get_plc_log(did)` now contains the fetched log (cache updated). 21 + 22 + **Additional coverage (multi-identity):** Phase 1, Task 3 tests `check_all` with two DIDs, both having only authorized operations. Asserts both `IdentityStatus` entries have `alert_count: 0`. 23 + 24 + --- 25 + 26 + ## AC6.2: Unauthorized operation detection 27 + 28 + **Criterion:** Monitor detects a new PLC operation signed by a different key and creates an `UnauthorizedChange` alert. 29 + 30 + | Field | Value | 31 + |-------|-------| 32 + | Verification | Automated test | 33 + | Test type | Integration | 34 + | Test file | `apps/identity-wallet/src-tauri/src/plc_monitor.rs` (`#[cfg(test)]` module) | 35 + | Implementation phase | Phase 1, Task 2 | 36 + 37 + **Test approach:** Generate two PLC operations: a genesis operation signed by the device key (cached), and a subsequent rotation operation signed by a different P-256 key (returned by mock plc.directory). Call `PlcMonitor::check_for_changes(did)`. Assert: (1) return value contains exactly one `UnauthorizedChange`, (2) `signing_key` matches the `did:key` URI of the non-device key, (3) `cid` matches the unauthorized operation's CID, (4) `operation` field contains the raw operation JSON. 38 + 39 + **Additional coverage (multi-identity):** Phase 1, Task 3 tests `check_all` with two DIDs, one with an unauthorized operation. Asserts the affected identity has `alert_count: 1` while the clean identity has `alert_count: 0`. 40 + 41 + --- 42 + 43 + ## AC6.3: Recovery deadline correctness 44 + 45 + **Criterion:** Alert includes correct recovery deadline (operation timestamp + 72 hours). 46 + 47 + | Field | Value | 48 + |-------|-------| 49 + | Verification | Automated test | 50 + | Test type | Unit + Integration | 51 + | Test file (backend) | `apps/identity-wallet/src-tauri/src/plc_monitor.rs` (`#[cfg(test)]` module) | 52 + | Test file (frontend) | `apps/identity-wallet/src/lib/utils/deadline.test.ts` | 53 + | Implementation phase | Phase 1, Task 2 (backend); Phase 3, Task 4 (frontend) | 54 + 55 + **Test approach (backend):** In the AC6.2 test above, additionally assert that `UnauthorizedChange.created_at` matches the `createdAt` value from the mock audit log entry. The `created_at` field is the raw ISO 8601 string from plc.directory; the 72-hour deadline is computed by the frontend from this value. 56 + 57 + **Test approach (frontend):** Unit tests in `deadline.test.ts` for the extracted utility functions: 58 + - `getDeadline('2026-03-29T12:00:00.000Z')` returns a `Date` exactly 72 hours later (`2026-04-01T12:00:00.000Z`). 59 + - `getUrgency(deadline, now)` returns correct urgency levels at threshold boundaries: `'safe'` (>24h), `'warning'` (4-24h), `'critical'` (<4h), `'expired'` (<=0). 60 + - `formatCountdown(deadline, now)` edge cases: exactly 72h remaining produces `'72h 0m remaining'`, 0 remaining produces `'Expired'`, 23h 59m remaining produces `'23h 59m remaining'`. 61 + 62 + --- 63 + 64 + ## AC6.4: Alert badge on home screen 65 + 66 + **Criterion:** Home screen shows red alert badge on identity cards with `alertCount > 0`. 67 + 68 + | Field | Value | 69 + |-------|-------| 70 + | Verification | **Human verification required** | 71 + | Test type | Visual / manual | 72 + | Test file | N/A (frontend UI rendering) | 73 + | Implementation phase | Phase 3, Task 1 and Task 3 | 74 + 75 + **Why automation is insufficient:** This criterion specifies visual rendering of a red alert badge on identity cards. The badge involves CSS styling (`.badge--alert` with `#fef2f2` background, `#ef4444` dot, `#991b1b` text), layout position relative to existing rotation key badges, and correct conditional rendering based on alert data. Svelte component rendering with scoped CSS and Tauri IPC data flow cannot be meaningfully verified by unit tests alone. 76 + 77 + **Automated verification (partial):** 78 + - `pnpm check` (Svelte type checking) verifies that `checkIdentityStatus` IPC types, `alertData` state, and `onalert` callback types are correct at compile time. 79 + - The backend `check_all` integration tests (Phase 1, Task 3) verify that `IdentityStatus.alert_count` is computed correctly, so the data driving the badge is trustworthy. 80 + 81 + **Human verification approach:** 82 + 1. Set up a test identity in the wallet with a known DID. 83 + 2. Use `httpmock` or a staging plc.directory to inject an unauthorized operation for that DID. 84 + 3. Open the app and navigate to the home screen. 85 + 4. Verify: (a) a red badge appears on the affected identity card showing the correct count, (b) identity cards with no alerts show no red badge, (c) tapping the alert badge navigates to the alert detail screen. 86 + 87 + --- 88 + 89 + ## AC6.5: Alert detail screen content 90 + 91 + **Criterion:** Alert detail screen shows signing key, timestamp, and recovery deadline countdown. 92 + 93 + | Field | Value | 94 + |-------|-------| 95 + | Verification | **Human verification required** (with automated support for deadline logic) | 96 + | Test type | Visual / manual (UI); Unit (deadline computation) | 97 + | Test file (partial) | `apps/identity-wallet/src/lib/utils/deadline.test.ts` | 98 + | Implementation phase | Phase 3, Task 2 and Task 4 | 99 + 100 + **Why full automation is insufficient:** The criterion requires verifying visual rendering of the `AlertDetailScreen` component: layout of signing key (truncated `did:key` URI or "Unknown key"), human-readable timestamp, a live countdown timer that updates every 60 seconds, and color-coded urgency indicators (green >24h, amber 4-24h, red <4h, red expired). These are visual and temporal behaviors that require a running app context. 101 + 102 + **Automated verification (partial):** 103 + - `deadline.test.ts` unit tests cover the pure computation: `getDeadline`, `getUrgency` thresholds, and `formatCountdown` formatting (see AC6.3 above). 104 + - `pnpm check` verifies that the component's `$props` types (`did`, `changes`, `onback`) match the data passed from the page state machine. 105 + 106 + **Human verification approach:** 107 + 1. Navigate to alert detail from an identity card with alerts (depends on AC6.4 verification). 108 + 2. Verify: (a) signing key displays as a `did:key:z...` URI (or "Unknown key" if null), (b) timestamp shows a human-readable date/time, (c) recovery deadline countdown is present and updates over time, (d) urgency color matches the remaining time (green/amber/red), (e) "Review & Override" button is visible but disabled, (f) back button returns to the home screen. 109 + 110 + --- 111 + 112 + ## AC6.6: Monitor lifecycle (foreground + timer) 113 + 114 + **Criterion:** Monitor runs on app foreground and on a 15-minute timer while app is open. 115 + 116 + | Field | Value | 117 + |-------|-------| 118 + | Verification | **Human verification required** (with automated support for cycle logic) | 119 + | Test type | Integration (cycle logic); Manual (lifecycle wiring) | 120 + | Test file (partial) | `apps/identity-wallet/src-tauri/src/plc_monitor.rs` (`#[cfg(test)]` module) | 121 + | Implementation phase | Phase 2, Task 1 (timer) and Task 2 (foreground) | 122 + 123 + **Why full automation is insufficient:** This criterion has two parts: 124 + 125 + 1. **15-minute timer:** The `run_monitoring_loop` function spawns a `tokio::time::interval` in the Tauri `.setup()` closure. Testing the actual timer integration requires a running Tauri app runtime, which is not available in `cargo test`. The `run_monitoring_cycle` helper (extracted for testability) can be unit-tested with mocks. 126 + 2. **App foreground:** The `visibilitychange` event listener in `+page.svelte` calls `checkIdentityStatus()` when the document becomes visible and the user is on the home screen. This requires a WKWebView context on an iOS device or simulator. 127 + 128 + **Automated verification (partial):** 129 + - `run_monitoring_cycle(&monitor)` is tested via the same `httpmock` integration tests as Phase 1 (it delegates to `check_all`). Tests verify that a single cycle produces correct `Vec<IdentityStatus>` results. 130 + - `cargo check` verifies that `run_monitoring_loop` compiles with correct Tauri types and the timer is wired into `.setup()`. 131 + - `pnpm check` verifies that the `visibilitychange` listener and `checkIdentityStatus` IPC call type-check. 132 + 133 + **Human verification approach:** 134 + 1. **Timer:** Launch the app and observe logs. After 15 minutes, verify that a monitoring cycle executes (look for `tracing` output from `run_monitoring_cycle` or `check_for_changes`). Inject a mock unauthorized operation between cycles and verify the `plc_alert` event fires on the next tick. 135 + 2. **Foreground:** Suspend the app (press Home), wait, then return to the app. Verify that `checkIdentityStatus` is called on resume (observable via network traffic to plc.directory or backend logs). 136 + 137 + --- 138 + 139 + ## AC6.7: Graceful handling of unreachable plc.directory 140 + 141 + **Criterion:** Monitor handles plc.directory being unreachable gracefully (logs error, retries next cycle, does not alert). 142 + 143 + | Field | Value | 144 + |-------|-------| 145 + | Verification | Automated test | 146 + | Test type | Integration | 147 + | Test file | `apps/identity-wallet/src-tauri/src/plc_monitor.rs` (`#[cfg(test)]` module) | 148 + | Implementation phase | Phase 1, Task 2 | 149 + 150 + **Test approach:** Configure `httpmock::MockServer` to return a network error (connection refused or 500 status) for the audit log endpoint. Call `PlcMonitor::check_for_changes(did)`. Assert: (1) return value is `Ok(vec![])` (no error propagated, no unauthorized changes), (2) no `UnauthorizedChange` alerts are created, (3) cached log is NOT updated (failure should not overwrite good cached data). 151 + 152 + --- 153 + 154 + ## AC6.8: Empty audit log handling 155 + 156 + **Criterion:** Monitor handles empty audit log (newly created identity, no operations yet). 157 + 158 + | Field | Value | 159 + |-------|-------| 160 + | Verification | Automated test | 161 + | Test type | Integration | 162 + | Test file | `apps/identity-wallet/src-tauri/src/plc_monitor.rs` (`#[cfg(test)]` module) | 163 + | Implementation phase | Phase 1, Task 2 | 164 + 165 + **Test approach:** Configure mock plc.directory to return an empty JSON array (`[]`) for the audit log. Ensure no cached log exists in `IdentityStore` (first check scenario). Call `PlcMonitor::check_for_changes(did)`. Assert: (1) return value is `Ok(vec![])`, (2) no errors, no alerts. This covers both the "no cached log" and "empty remote log" paths. 166 + 167 + --- 168 + 169 + ## Coverage Matrix 170 + 171 + | AC | Automated Test | Human Verification | Phase.Task | 172 + |----|:-:|:-:|---| 173 + | AC6.1 | Yes | -- | P1.T2, P1.T3 | 174 + | AC6.2 | Yes | -- | P1.T2, P1.T3 | 175 + | AC6.3 | Yes (backend + frontend unit) | -- | P1.T2, P3.T4 | 176 + | AC6.4 | Partial (type check) | **Yes** | P3.T1, P3.T3 | 177 + | AC6.5 | Partial (deadline unit) | **Yes** | P3.T2, P3.T4 | 178 + | AC6.6 | Partial (cycle logic) | **Yes** | P2.T1, P2.T2 | 179 + | AC6.7 | Yes | -- | P1.T2 | 180 + | AC6.8 | Yes | -- | P1.T2 | 181 + 182 + **Summary:** 5 of 8 acceptance criteria (AC6.1, AC6.2, AC6.3, AC6.7, AC6.8) are fully verifiable by automated tests. 3 criteria (AC6.4, AC6.5, AC6.6) require human verification due to visual rendering, real-time countdown behavior, or app lifecycle integration that cannot be exercised in headless test environments. All 3 have partial automated coverage (type checking, pure function unit tests, or backend integration tests) that reduces the surface area of manual verification.