diff --git a/README.md b/README.md index 8986d36..eef7e9a 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ STA will provide a modern web interface for controlling Modbus-compatible relay - ✅ Health check and metadata API endpoints - ✅ OpenAPI documentation with Swagger UI - ✅ Rate limiting middleware +- ✅ CORS middleware (configurable for production) - ✅ SQLite schema and repository for relay labels - ✅ Vue 3 + TypeScript frontend scaffolding with Vite - ✅ Type-safe API client generation from OpenAPI specs diff --git a/specs/001-modbus-relay-control/plan.md b/specs/001-modbus-relay-control/plan.md index dbe6f93..4eb3ba2 100644 --- a/specs/001-modbus-relay-control/plan.md +++ b/specs/001-modbus-relay-control/plan.md @@ -20,7 +20,7 @@ **Language/Version**: Rust 1.75+ **Primary Dependencies**: - tokio-modbus 0.17.0 with TCP feature only (Modbus TCP protocol) -- Poem 3.1 + poem-openapi 5.1 (HTTP API with OpenAPI) +- Poem 3.1 + poem-openapi 5.1 (HTTP API with OpenAPI + CORS middleware) - Tokio 1.48 (async runtime) - sqlx 0.8 (SQLite persistence with compile-time verification) - mockall + async-trait (testing) @@ -77,6 +77,7 @@ specs/001-modbus-relay-control/ ├── spec.md # Feature specification ├── decisions.md # Architecture and technical decisions ├── research.md # Technical research findings +├── research-cors.md # CORS configuration research and decisions └── types-design.md # Type system design (TyDD) ``` diff --git a/specs/001-modbus-relay-control/research-cors.md b/specs/001-modbus-relay-control/research-cors.md new file mode 100644 index 0000000..3fd0ed5 --- /dev/null +++ b/specs/001-modbus-relay-control/research-cors.md @@ -0,0 +1,465 @@ +# CORS Configuration Research + +**Date**: 2026-01-02 +**Feature**: Configurable CORS for Modbus Relay Control System +**Research Focus**: Production-ready CORS configuration to replace permissive defaults + +## Executive Summary + +Current CORS implementation uses `Cors::new()` with default settings that allow ALL origins, ALL methods, and ALL headers - acceptable for development but insecure for production. This research documents how to implement configurable CORS following the project's existing patterns for middleware configuration. + +## Current State Analysis + +### Implementation Location +- **File**: `backend/src/startup.rs:86` +- **Current Code**: `.with(Cors::new())` +- **Status**: Hardcoded permissive CORS with no configuration + +### Security Profile (Current) +- ✗ Allows ALL origins (including potentially malicious sites) +- ✗ Allows ALL HTTP methods (GET, POST, DELETE, PATCH, etc.) +- ✗ Allows ALL request headers +- ✓ Does NOT allow credentials (default: false) +- ✓ 24-hour preflight cache (default max_age: 86400) + +### Configuration Gap +- `settings.frontend_url` exists in `backend/src/settings.rs:20` but is NOT used for CORS +- No CORS-specific settings struct +- No environment-aware CORS configuration + +## Research Findings + +### 1. Poem CORS API + +**Core Configuration Methods**: +```rust +Cors::new() + .allow_origin(origin: &str) // Single origin + .allow_origins(origins: Vec<&str>) // Multiple origins + .allow_origin_regex(pattern: &str) // Wildcard patterns + .allow_method(method: Method) // HTTP method + .allow_methods(methods: Vec) // Multiple methods + .allow_header(header: HeaderName) // Request header + .allow_headers(headers: Vec) // Multiple headers + .expose_header(header: HeaderName) // Response header + .expose_headers(headers: Vec) // Multiple response headers + .allow_credentials(bool) // Cookie/auth support + .max_age(seconds: i32) // Preflight cache duration +``` + +**Default Behavior**: +- Empty collections → permits ALL values (permissive) +- `max_age`: 86400 seconds (24 hours) +- `allow_credentials`: false + +### 2. Production Security Best Practices + +#### Origin Configuration +| Approach | Security | Use Case | +|----------|----------|----------| +| Specific origin | High | Production (single frontend domain) | +| Multiple specific origins | High | Multi-environment (dev + prod) | +| Wildcard patterns | Medium | Subdomains (*.example.com) | +| Custom validation function | Medium | Complex rules | +| Default (empty) | Low | Development only | + +**Recommendation**: Use specific origins from `frontend_url` setting. + +#### Credentials +- **false**: Public APIs without authentication +- **true**: APIs requiring cookies/auth headers (Authelia requires this) + +**For STA**: Set to `true` because Traefik uses Authelia authentication. + +**Critical**: When credentials=true, wildcard `*` origin is NOT allowed by browsers. Must use specific origins. + +#### Methods +- **Minimal**: GET only (read-only endpoints) +- **Standard**: GET, POST (basic REST) +- **Full**: GET, POST, PUT, DELETE, PATCH (complete CRUD) + +**For STA**: GET, POST, PUT (relay control requires POST/PUT for state changes) + +#### Headers +- **Essential**: Content-Type (for POST/PUT bodies) +- **Authentication**: Authorization (for bearer tokens) +- **Custom**: X-Request-Id, X-Custom-Header (application-specific) + +**For STA**: Minimum required: `content-type`, `authorization` + +#### Max Age +| Duration | Implications | Security | +|----------|--------------|----------| +| 0 | No caching, preflight every request | High but inefficient | +| 3600 (1 hour) | Balance security & performance | High (recommended) | +| 86400 (24 hours) | Fewer preflights, slower updates | Medium | +| 604800 (7 days) | Very few preflights | Low | + +**Recommendation**: 3600 seconds (1 hour) for production. + +### 3. Deployment Architecture Context + +From `specs/001-modbus-relay-control/decisions.md`: + +**Production Setup**: +- **Frontend**: Cloudflare Pages (static hosting with global CDN) +- **Backend**: Raspberry Pi 3B+ (local network, same as Modbus device) +- **Reverse Proxy**: Traefik on Raspberry Pi + - HTTPS termination (TLS certificates) + - Authelia middleware (user authentication) + - Routes HTTPS requests to backend HTTP service + +**Communication Flow**: +``` +Frontend (Cloudflare Pages) + ↓ HTTPS +Traefik (Raspberry Pi) + ↓ Authelia authentication + ↓ HTTP (local network) +Backend (Raspberry Pi) + ↓ Modbus TCP +Relay Device (local network) +``` + +**CORS Implications**: +- Origin must be the Cloudflare Pages URL (HTTPS) +- Credentials must be allowed (Authelia auth tokens) +- Backend sees requests from Traefik, but CORS origin is frontend domain + +### 4. Configuration Pattern (from Rate Limiting) + +**Reference**: `backend/src/middleware/rate_limit.rs` + +**Pattern Structure**: +1. **Configuration struct** with settings +2. **Middleware implementation** using configuration +3. **Conditional instantiation** in `startup.rs` based on settings +4. **YAML configuration** in `settings/*.yaml` +5. **Environment variable overrides** via `APP__` prefix + +**Example from Rate Limiting**: +```rust +// Settings struct +pub struct RateLimitSettings { + pub enabled: bool, + pub burst_size: u32, + pub per_seconds: u64, +} + +// Conditional instantiation in startup.rs +let rate_limit_config = if value.settings.rate_limit.enabled { + RateLimitConfig::new(burst_size, per_seconds) +} else { + RateLimitConfig::new(u32::MAX, 1) // Effectively disabled +}; + +let app = value.app + .with(RateLimit::new(&rate_limit_config)) + .with(Cors::new()) // ← Should follow same pattern + .data(value.settings); +``` + +## Recommended Approach + +### Option A: Minimal Configuration (MVP) + +Add simple CORS settings that cover 80% of use cases: + +```rust +#[derive(Debug, serde::Deserialize, Clone)] +pub struct CorsSettings { + pub allowed_origins: Vec, + pub allow_credentials: bool, +} + +impl Default for CorsSettings { + fn default() -> Self { + Self { + allowed_origins: vec!["*".to_string()], + allow_credentials: false, + } + } +} +``` + +**YAML Configuration**: +```yaml +# development.yaml +cors: + allowed_origins: + - "http://localhost:3000" + - "http://127.0.0.1:3000" + allow_credentials: false + +# production.yaml +cors: + allowed_origins: + - "https://REACTED" # Cloudflare Pages URL + allow_credentials: true +``` + +**Pros**: +- Simple to implement +- Covers most critical security concerns +- Easy to understand and maintain + +**Cons**: +- Limited flexibility (no method/header customization) +- Cannot disable CORS entirely +- No max_age configuration + +### Option B: Full Configuration (Production-Ready) + +Complete CORS configuration matching Poem's capabilities: + +```rust +#[derive(Debug, serde::Deserialize, Clone)] +pub struct CorsSettings { + pub enabled: bool, + pub allowed_origins: Vec, + pub allowed_methods: Vec, + pub allowed_headers: Vec, + pub expose_headers: Vec, + pub allow_credentials: bool, + pub max_age_secs: i32, +} + +impl Default for CorsSettings { + fn default() -> Self { + Self { + enabled: true, + allowed_origins: vec!["*".to_string()], + allowed_methods: vec!["GET".to_string(), "POST".to_string(), "PUT".to_string()], + allowed_headers: vec!["content-type".to_string(), "authorization".to_string()], + expose_headers: vec![], + allow_credentials: false, + max_age_secs: 3600, + } + } +} +``` + +**YAML Configuration**: +```yaml +# production.yaml +cors: + enabled: true + allowed_origins: + - "https://REDACTED" + allowed_methods: + - "GET" + - "POST" + - "PUT" + allowed_headers: + - "content-type" + - "authorization" + expose_headers: + - "x-ratelimit-remaining" + allow_credentials: true + max_age_secs: 3600 +``` + +**Pros**: +- Complete control over CORS behavior +- Can disable CORS entirely (enabled: false) +- Production-ready security +- Follows Poem API closely + +**Cons**: +- More complex configuration +- More YAML to maintain +- Risk of misconfiguration + +### Option C: Hybrid Approach (Recommended) + +Combine simplicity with essential production features: + +```rust +#[derive(Debug, serde::Deserialize, Clone)] +pub struct CorsSettings { + pub allowed_origins: Vec, + pub allow_credentials: bool, + pub max_age_secs: i32, +} + +impl Default for CorsSettings { + fn default() -> Self { + Self { + allowed_origins: vec!["*".to_string()], + allow_credentials: false, + max_age_secs: 3600, + } + } +} +``` + +**Methods and Headers**: Hardcoded in code based on API requirements (not configurable). + +**Rationale**: +- Origins and credentials are deployment-specific (need configuration) +- Methods and headers are API-specific (shouldn't change per environment) +- Simpler than Option B, more secure than Option A +- Reduces configuration surface area + +## Implementation Checklist + +### Files to Modify + +1. **`backend/src/settings.rs`** + - Add `CorsSettings` struct (after `RateLimitSettings`) + - Add `cors: CorsSettings` field to `Settings` struct + - Add `#[serde(default)]` attribute for backward compatibility + +2. **`backend/src/startup.rs`** + - Import CORS-related types from Poem + - Create helper method `build_cors(settings: &CorsSettings) -> Cors` + - Replace `.with(Cors::new())` with `.with(Self::build_cors(&value.settings.cors))` + +3. **`backend/settings/development.yaml`** + - Add `cors:` section with permissive development settings + - Allow localhost:3000 and 127.0.0.1:3000 + +4. **`backend/settings/production.yaml`** + - Add `cors:` section with restrictive production settings + - Use actual Cloudflare Pages URL + - Set `allow_credentials: true` + +5. **`specs/001-modbus-relay-control/plan.md`** + - Update technical context with CORS configuration + - Add CORS configuration task to implementation plan + +6. **`CLAUDE.md`** (optional) + - Document CORS configuration in project instructions + +### Testing Strategy + +1. **Unit Tests**: Verify `build_cors()` creates correct Poem `Cors` instance +2. **Integration Tests**: Verify CORS headers in HTTP responses +3. **Manual Tests**: Test with actual frontend (Vite dev server + Cloudflare Pages) + +## Key Files Referenced + +| File | Lines | Purpose | +|------|-------|---------| +| `/backend/src/startup.rs` | 9, 23, 83-87 | Current CORS implementation | +| `/backend/src/settings.rs` | 13-28, 75-90 | Settings structure | +| `/backend/src/middleware/rate_limit.rs` | 16-127 | Middleware pattern reference | +| `/backend/settings/development.yaml` | 1-9 | Development configuration | +| `/backend/settings/production.yaml` | 1-9 | Production configuration | +| `/specs/001-modbus-relay-control/decisions.md` | 71-92 | Deployment architecture | + +## Security Considerations + +### Critical Points + +1. **Never use wildcard `*` origin with credentials enabled** - browsers reject this +2. **Specific origins only in production** - wildcards increase attack surface +3. **Minimal methods and headers** - only expose what's needed by API +4. **1-hour max_age** - allows policy updates within reasonable timeframe +5. **Test with actual frontend** - CORS errors only appear in browser, not curl/Postman + +### Attack Vectors Mitigated + +- **CSRF via foreign domains**: Specific origins prevent malicious sites from making requests +- **Credential theft**: Credentials only sent to whitelisted origins +- **Data exfiltration**: Restrictive CORS prevents unauthorized cross-origin reads + +## User Decisions (2026-01-02) + +### Q1: Configuration Approach +**Decision**: **Option C (Hybrid)** - Configure origins, credentials, and max_age +**Rationale**: Balance of simplicity and production needs. Methods/headers hardcoded based on API requirements. + +### Q2: Production URL +**Decision**: `https://REDACTED` +**Usage**: Set in `production.yaml` as allowed origin + +### Q3: Development Port +**Decision**: Port 5173 (Vite's default) +**Note**: Previous `frontend_url: http://localhost:3000` was leftover from Nuxt project. Update to 5173. + +### Q4: Exposed Headers +**Decision**: Not needed for v1.0 +**Future**: May add in v1.1+ (e.g., `x-ratelimit-remaining`) + +### Q5: CORS Disable Option +**Decision**: No `enabled` flag +**Approach**: Development.yaml will have permissive settings (`*` origin, all methods) + +### Q6: Default Behavior +**Decision**: Restrictive (fail-safe) when `cors:` section missing +**Note**: Development.yaml explicitly sets permissive settings, so this only affects missing config + +### Q7: Multiple Origins Support +**Decision**: Nice to have, include support via `Vec` +**Implementation**: Use array in YAML, trivial to support + +### Q8: Specification Update +**Decision**: Add new FR-023 for configurable CORS in production +**Action**: Update `spec.md` with new functional requirement + +## Implementation Decisions Summary + +**Configuration Structure**: +```rust +#[derive(Debug, serde::Deserialize, Clone)] +pub struct CorsSettings { + pub allowed_origins: Vec, + pub allow_credentials: bool, + pub max_age_secs: i32, +} + +impl Default for CorsSettings { + fn default() -> Self { + Self { + // Restrictive default (fail-safe) + allowed_origins: vec![], + allow_credentials: false, + max_age_secs: 3600, + } + } +} +``` + +**Development Configuration** (`development.yaml`): +```yaml +cors: + allowed_origins: + - "*" # Permissive for local development + allow_credentials: false + max_age_secs: 3600 + +frontend_url: http://localhost:5173 # Updated from 3000 +``` + +**Production Configuration** (`production.yaml`): +```yaml +cors: + allowed_origins: + - "https://REDACTED" + allow_credentials: true # Required for Authelia authentication + max_age_secs: 3600 + +frontend_url: "https://REDACTED" +``` + +**Hardcoded Settings** (in code, not configurable): +- **Methods**: GET, POST, PUT (based on API requirements) +- **Allowed Headers**: content-type, authorization (minimum for API) +- **Exposed Headers**: None for v1.0 + +## Next Steps + +1. ✅ **Clarify ambiguities** - Complete +2. ✅ **Choose approach** - Option C (Hybrid) +3. **Design architecture** with multiple implementation approaches +4. **Get user approval** on preferred approach +5. **Generate implementation plan** with tasks +6. **Review plan** for completeness + +## References + +- Poem CORS documentation: Inferred from codebase patterns +- CORS specification: MDN Web Docs (Cross-Origin Resource Sharing) +- Project constitution: `specs/constitution.md` v1.1.0 +- Deployment decisions: `specs/001-modbus-relay-control/decisions.md` diff --git a/specs/001-modbus-relay-control/spec.md b/specs/001-modbus-relay-control/spec.md index d8e5f7f..d1af67f 100644 --- a/specs/001-modbus-relay-control/spec.md +++ b/specs/001-modbus-relay-control/spec.md @@ -169,7 +169,13 @@ As a user, I want to assign custom labels to each relay (e.g., "Garage Light", " - **FR-019**: System MUST return HTTP 504 for Modbus timeout errors - **FR-020**: System MUST include OpenAPI 3.0 specification accessible at `/api/specs` - **FR-021**: System MUST apply rate limiting middleware (100 requests/minute per IP) -- **FR-022**: System MUST apply CORS middleware allowing all origins (local network deployment) +- **FR-022**: System MUST apply CORS middleware in development allowing all origins (`*`) for local development (port 5173) +- **FR-022a**: System MUST apply configurable CORS middleware in production with: + - Specific allowed origin from configuration (default: `https://REDACTED`) + - Credential support for Authelia authentication (`allow_credentials: true`) + - Configurable preflight cache duration (default: 1 hour) + - Hardcoded HTTP methods: GET, POST, PUT, PATCH, DELETE, OPTIONS + - Hardcoded allowed headers: content-type, authorization - **FR-023**: System MUST start successfully even if Modbus device is unreachable at startup, marking device as unhealthy - **FR-024**: System MUST persist relay labels to configuration file (YAML) for persistence across restarts diff --git a/specs/001-modbus-relay-control/tasks.md b/specs/001-modbus-relay-control/tasks.md index 3bdea22..5a12e93 100644 --- a/specs/001-modbus-relay-control/tasks.md +++ b/specs/001-modbus-relay-control/tasks.md @@ -1,14 +1,14 @@ # Implementation Tasks: Modbus Relay Control System **Feature**: 001-modbus-relay-control -**Total Tasks**: 94 tasks across 8 phases -**MVP Delivery**: Phase 4 complete (Task 49) -**Parallelizable Tasks**: 35 tasks marked with `[P]` +**Total Tasks**: 102 tasks across 9 phases +**MVP Delivery**: Phase 4 complete (Task 57) +**Parallelizable Tasks**: 39 tasks marked with `[P]` **Approach**: Type-Driven Development (TyDD) + Test-Driven Development (TDD), Backend API first --- -## Phase 1: Setup & Foundation (0.5 days) +## Phase 1: Setup & Foundation (0.5 days) DONE **Purpose**: Initialize project dependencies and directory structure @@ -55,7 +55,7 @@ - **Test**: `npm run dev` starts frontend dev server - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T008** [P] [Setup] [TDD] Generate TypeScript API client from OpenAPI +- [x] **T008** [P] [Setup] [TDD] Generate TypeScript API client from OpenAPI - Add poem-openapi spec generation in startup.rs - Generate frontend/src/api/client.ts from OpenAPI spec - **Test**: TypeScript client compiles without errors @@ -64,13 +64,140 @@ --- +## Phase 0.5: CORS Configuration & Production Security (0.5 days) + +**Purpose**: Replace permissive `Cors::new()` with configurable production-ready CORS + +**⚠️ TDD CRITICAL**: Write failing tests FIRST for every configuration and function + +- [x] **T009** [P] [Setup] [TDD] Write tests for CorsSettings struct + - Test: CorsSettings deserializes from YAML correctly ✓ + - Test: Default CorsSettings has empty allowed_origins (restrictive fail-safe) ✓ + - Test: CorsSettings with wildcard origin deserializes correctly ✓ + - Test: Settings::new() loads cors section from development.yaml ✓ + - Test: CorsSettings with partial fields uses defaults ✓ + - **File**: backend/src/settings.rs (in tests module) + - **Complexity**: Low | **Uncertainty**: Low + - **Tests Written**: 5 tests (cors_settings_deserialize_from_yaml, cors_settings_default_has_empty_origins, cors_settings_with_wildcard_deserializes, settings_loads_cors_section_from_yaml, cors_settings_deserialize_with_defaults) + +- [ ] **T010** [P] [Setup] [TDD] Add CorsSettings struct to settings.rs + - Struct fields: `allowed_origins: Vec`, `allow_credentials: bool`, `max_age_secs: i32` + - Implement Default with restrictive settings: `allowed_origins: vec![]`, `allow_credentials: false`, `max_age_secs: 3600` + - Add `#[derive(Debug, serde::Deserialize, Clone)]` to struct + - Add `#[serde(default)]` attribute to Settings.cors field + - Update Settings struct to include `pub cors: CorsSettings` + - **File**: backend/src/settings.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T011** [Setup] [TDD] Update development.yaml with permissive CORS settings + - Add cors section with `allowed_origins: ["*"]`, `allow_credentials: false`, `max_age_secs: 3600` + - Update `frontend_url` from `http://localhost:3000` to `http://localhost:5173` (Vite default port) + - **Test**: cargo run loads development config without errors + - **File**: backend/settings/development.yaml + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T012** [P] [Setup] [TDD] Create production.yaml with restrictive CORS settings + - Add cors section with `allowed_origins: ["REACTED"]`, `allow_credentials: true`, `max_age_secs: 3600` + - Add `frontend_url: "https://REDACTED"` + - Add production-specific application settings (protocol: https, host: 0.0.0.0) + - **Test**: Settings::new() with APP_ENVIRONMENT=production loads config + - **File**: backend/settings/production.yaml + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T013** [Setup] [TDD] Write tests for build_cors() function + - Test: build_cors() with wildcard origin creates permissive Cors (allows any origin) + - Test: build_cors() with specific origin creates restrictive Cors + - Test: build_cors() with `credentials=true` and wildcard origin returns error (browser constraint violation) + - Test: build_cors() sets correct methods (GET, POST, PUT, PATCH, DELETE, OPTIONS) + - Test: build_cors() sets correct headers (content-type, authorization) + - Test: build_cors() sets max_age from settings + - **File**: backend/src/startup.rs (in tests module) + - **Complexity**: Medium | **Uncertainty**: Low + +- [ ] **T014** [Setup] [TDD] Implement build_cors() free function in startup.rs + - Function signature: `fn build_cors(settings: &CorsSettings) -> Cors` + - Validate: if `allow_credentials=true` AND `allowed_origins` contains "*", panic with clear error message + - Iterate over `allowed_origins` and call `cors.allow_origin()` for each + - Hardcode methods: `vec![Method::GET, Method::POST, Method::PUT, Method::PATCH, Method::DELETE, Method::OPTIONS]` + - Hardcode headers: `vec![header::CONTENT_TYPE, header::AUTHORIZATION]` + - Set `allow_credentials` from settings + - Set `max_age` from settings.max_age_secs + - Add structured logging: `tracing::info!(allowed_origins = ?settings.allowed_origins, allow_credentials = settings.allow_credentials, "CORS middleware configured")` + - **File**: backend/src/startup.rs + - **Complexity**: Medium | **Uncertainty**: Low + + **Pseudocode**: + ```rust + fn build_cors(settings: &CorsSettings) -> Cors { + // Validation + if settings.allow_credentials && settings.allowed_origins.contains(&"*".to_string()) { + panic!("CORS misconfiguration: wildcard origin not allowed with credentials=true"); + } + + let mut cors = Cors::new(); + + // Configure origins + for origin in &settings.allowed_origins { + cors = cors.allow_origin(origin.as_str()); + } + + // Hardcoded methods (API-specific) + cors = cors.allow_methods(vec![ + Method::GET, Method::POST, Method::PUT, + Method::PATCH, Method::DELETE, Method::OPTIONS + ]); + + // Hardcoded headers (minimum for API) + cors = cors.allow_headers(vec![ + header::CONTENT_TYPE, + header::AUTHORIZATION, + ]); + + // Configure from settings + cors = cors + .allow_credentials(settings.allow_credentials) + .max_age(settings.max_age_secs); + + tracing::info!( + target: "backend::startup", + allowed_origins = ?settings.allowed_origins, + allow_credentials = settings.allow_credentials, + max_age_secs = settings.max_age_secs, + "CORS middleware configured" + ); + + cors + } + ``` + +- [ ] **T015** [Setup] [TDD] Replace Cors::new() with build_cors() in middleware chain + - In `From for RunnableApplication`, replace `.with(Cors::new())` with `.with(build_cors(&value.settings.cors))` + - Add necessary imports: `poem::http::{Method, header}` + - Ensure CORS is applied after rate limiting (order: RateLimit → CORS → Data) + - **Test**: Integration test verifies CORS headers are present + - **File**: backend/src/startup.rs (line ~86) + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T016** [P] [Setup] [TDD] Write integration tests for CORS headers + - Test: OPTIONS preflight request to `/api/health` returns correct CORS headers + - Test: GET `/api/health` with Origin header returns `Access-Control-Allow-Origin` header + - Test: Preflight response includes `Access-Control-Max-Age` matching configuration + - Test: Response includes `Access-Control-Allow-Credentials` when configured + - Test: Response includes correct `Access-Control-Allow-Methods` (GET, POST, PUT, PATCH, DELETE, OPTIONS) + - **File**: backend/tests/integration/cors_test.rs (new file) + - **Complexity**: Medium | **Uncertainty**: Low + +**Checkpoint**: CORS configuration complete, production-ready security with environment-specific settings + +--- + ## Phase 2: Domain Layer - Type-Driven Development (1 day) **Purpose**: Build domain types with 100% test coverage, bottom-to-top **⚠️ TDD CRITICAL**: Write failing tests FIRST for every type, then implement -- [ ] **T009** [US1] [TDD] Write tests for RelayId newtype +- [ ] **T017** [US1] [TDD] Write tests for RelayId newtype - Test: RelayId::new(1) → Ok(RelayId(1)) - Test: RelayId::new(8) → Ok(RelayId(8)) - Test: RelayId::new(0) → Err(InvalidRelayId) @@ -79,27 +206,27 @@ - **File**: src/domain/relay.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T010** [US1] [TDD] Implement RelayId newtype with validation +- [ ] **T018** [US1] [TDD] Implement RelayId newtype with validation - #[repr(transparent)] newtype wrapping u8 - Constructor validates 1..=8 range - Implement Display, Debug, Clone, Copy, PartialEq, Eq - **File**: src/domain/relay.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T011** [P] [US1] [TDD] Write tests for RelayState enum +- [ ] **T019** [P] [US1] [TDD] Write tests for RelayState enum - Test: RelayState::On → serializes to "on" - Test: RelayState::Off → serializes to "off" - Test: Parse "on"/"off" from strings - **File**: src/domain/relay.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T012** [P] [US1] [TDD] Implement RelayState enum +- [ ] **T020** [P] [US1] [TDD] Implement RelayState enum - Enum: On, Off - Implement Display, Debug, Clone, Copy, PartialEq, Eq, serde::Serialize/Deserialize - **File**: src/domain/relay.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T013** [US1] [TDD] Write tests for Relay aggregate +- [ ] **T021** [US1] [TDD] Write tests for Relay aggregate - Test: Relay::new(RelayId(1), RelayState::Off, None) creates relay - Test: relay.toggle() flips state - Test: relay.turn_on() sets state to On @@ -107,13 +234,13 @@ - **File**: src/domain/relay.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T014** [US1] [TDD] Implement Relay aggregate +- [ ] **T022** [US1] [TDD] Implement Relay aggregate - Struct: Relay { id: RelayId, state: RelayState, label: Option } - Methods: new(), toggle(), turn_on(), turn_off(), state(), label() - **File**: src/domain/relay.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T015** [P] [US4] [TDD] Write tests for RelayLabel newtype +- [ ] **T023** [P] [US4] [TDD] Write tests for RelayLabel newtype - Test: RelayLabel::new("Pump") → Ok - Test: RelayLabel::new("A".repeat(50)) → Ok - Test: RelayLabel::new("") → Err(EmptyLabel) @@ -121,26 +248,26 @@ - **File**: src/domain/relay.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T016** [P] [US4] [TDD] Implement RelayLabel newtype +- [ ] **T024** [P] [US4] [TDD] Implement RelayLabel newtype - #[repr(transparent)] newtype wrapping String - Constructor validates 1..=50 length - Implement Display, Debug, Clone, PartialEq, Eq - **File**: src/domain/relay.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T017** [US1] [TDD] Write tests for ModbusAddress type +- [ ] **T025** [US1] [TDD] Write tests for ModbusAddress type - Test: ModbusAddress::from(RelayId(1)) → ModbusAddress(0) - Test: ModbusAddress::from(RelayId(8)) → ModbusAddress(7) - **File**: src/domain/modbus.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T018** [US1] [TDD] Implement ModbusAddress type with From +- [ ] **T026** [US1] [TDD] Implement ModbusAddress type with From - #[repr(transparent)] newtype wrapping u16 - Implement From with offset: user 1-8 → Modbus 0-7 - **File**: src/domain/modbus.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T019** [US3] [TDD] Write tests and implement HealthStatus enum +- [ ] **T027** [US3] [TDD] Write tests and implement HealthStatus enum - Enum: Healthy, Degraded { consecutive_errors: u32 }, Unhealthy { reason: String } - Test transitions between states - **File**: src/domain/health.rs @@ -154,20 +281,20 @@ **Purpose**: Implement Modbus client, mocks, and persistence -- [ ] **T020** [P] [US1] [TDD] Write tests for MockRelayController +- [ ] **T028** [P] [US1] [TDD] Write tests for MockRelayController - Test: read_state() returns mocked state - Test: write_state() updates mocked state - Test: read_all() returns 8 relays in known state - **File**: src/infrastructure/modbus/mock_controller.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T021** [P] [US1] [TDD] Implement MockRelayController +- [ ] **T029** [P] [US1] [TDD] Implement MockRelayController - Struct with Arc>> - Implement RelayController trait with in-memory state - **File**: src/infrastructure/modbus/mock_controller.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T022** [US1] [TDD] Define RelayController trait +- [ ] **T030** [US1] [TDD] Define RelayController trait - async fn read_state(&self, id: RelayId) → Result - async fn write_state(&self, id: RelayId, state: RelayState) → Result<(), ControllerError> - async fn read_all(&self) → Result, ControllerError> @@ -175,14 +302,14 @@ - **File**: src/infrastructure/modbus/controller.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T023** [P] [US1] [TDD] Define ControllerError enum +- [ ] **T031** [P] [US1] [TDD] Define ControllerError enum - Variants: ConnectionError(String), Timeout(u64), ModbusException(String), InvalidRelayId(u8) - Implement std::error::Error, Display, Debug - Use thiserror derive macros - **File**: src/infrastructure/modbus/error.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T024** [US1] [TDD] Write tests for ModbusRelayController +- [ ] **T032** [US1] [TDD] Write tests for ModbusRelayController - **REQUIRES HARDWARE/MOCK**: Integration test with tokio_modbus::test utilities - Test: Connection succeeds with valid config (Modbus TCP on port 502) - Test: read_state() returns correct coil value @@ -390,43 +517,43 @@ --- -- [ ] **T026** [US1] [TDD] Integration test with real hardware (optional) +- [ ] **T034** [US1] [TDD] Integration test with real hardware (optional) - **REQUIRES PHYSICAL DEVICE**: Test against actual Modbus relay at configured IP - Skip if device unavailable, rely on MockRelayController for CI - **File**: tests/integration/modbus_hardware_test.rs - **Complexity**: Medium | **Uncertainty**: High - **Note**: Use #[ignore] attribute, run with cargo test -- --ignored -- [ ] **T027** [P] [US4] [TDD] Write tests for RelayLabelRepository trait +- [ ] **T035** [P] [US4] [TDD] Write tests for RelayLabelRepository trait - Test: get_label(RelayId(1)) → Option - Test: set_label(RelayId(1), label) → Result<(), RepositoryError> - Test: delete_label(RelayId(1)) → Result<(), RepositoryError> - **File**: src/infrastructure/persistence/label_repository.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T028** [P] [US4] [TDD] Implement SQLite RelayLabelRepository +- [ ] **T036** [P] [US4] [TDD] Implement SQLite RelayLabelRepository - Implement get_label(), set_label(), delete_label() using SQLx - Use sqlx::query! macros for compile-time SQL verification - **File**: src/infrastructure/persistence/sqlite_label_repository.rs - **Complexity**: Medium | **Uncertainty**: Low -- [ ] **T029** [US4] [TDD] Write tests for in-memory mock LabelRepository +- [ ] **T037** [US4] [TDD] Write tests for in-memory mock LabelRepository - For testing without SQLite dependency - **File**: src/infrastructure/persistence/mock_label_repository.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T030** [US4] [TDD] Implement in-memory mock LabelRepository +- [ ] **T038** [US4] [TDD] Implement in-memory mock LabelRepository - HashMap-based implementation - **File**: src/infrastructure/persistence/mock_label_repository.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T031** [US3] [TDD] Write tests for HealthMonitor service +- [ ] **T039** [US3] [TDD] Write tests for HealthMonitor service - Test: track_success() transitions Degraded → Healthy - Test: track_failure() transitions Healthy → Degraded → Unhealthy - **File**: src/application/health_monitor.rs - **Complexity**: Medium | **Uncertainty**: Low -- [ ] **T032** [US3] [TDD] Implement HealthMonitor service +- [ ] **T040** [US3] [TDD] Implement HealthMonitor service - Track consecutive errors, transition states per FR-020, FR-021 - **File**: src/application/health_monitor.rs - **Complexity**: Medium | **Uncertainty**: Low @@ -443,36 +570,36 @@ ### Application Layer -- [ ] **T033** [US1] [TDD] Write tests for ToggleRelayUseCase +- [ ] **T041** [US1] [TDD] Write tests for ToggleRelayUseCase - Test: execute(RelayId(1)) toggles relay state via controller - Test: execute() returns error if controller fails - **File**: src/application/use_cases/toggle_relay.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T034** [US1] [TDD] Implement ToggleRelayUseCase +- [ ] **T042** [US1] [TDD] Implement ToggleRelayUseCase - Orchestrate: read current state → toggle → write new state - **File**: src/application/use_cases/toggle_relay.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T035** [P] [US1] [TDD] Write tests for GetAllRelaysUseCase +- [ ] **T043** [P] [US1] [TDD] Write tests for GetAllRelaysUseCase - Test: execute() returns all 8 relays with states - **File**: src/application/use_cases/get_all_relays.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T036** [P] [US1] [TDD] Implement GetAllRelaysUseCase +- [ ] **T044** [P] [US1] [TDD] Implement GetAllRelaysUseCase - Call controller.read_all(), map to domain Relay objects - **File**: src/application/use_cases/get_all_relays.rs - **Complexity**: Low | **Uncertainty**: Low ### Presentation Layer (Backend API) -- [ ] **T037** [US1] [TDD] Define RelayDto in presentation layer +- [ ] **T045** [US1] [TDD] Define RelayDto in presentation layer - Fields: id (u8), state ("on"/"off"), label (Option) - Implement From for RelayDto - **File**: src/presentation/dto/relay_dto.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T038** [US1] [TDD] Define API error responses +- [ ] **T046** [US1] [TDD] Define API error responses - ApiError enum with status codes and messages - Implement poem::error::ResponseError - **File**: src/presentation/error.rs @@ -627,26 +754,26 @@ --- -- [ ] **T040** [US1] [TDD] Write contract tests for GET /api/relays +- [ ] **T048** [US1] [TDD] Write contract tests for GET /api/relays - Test: Returns 200 with array of 8 RelayDto - Test: Each relay has id 1-8, state, and optional label - **File**: tests/contract/test_relay_api.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T041** [US1] [TDD] Implement GET /api/relays endpoint +- [ ] **T049** [US1] [TDD] Implement GET /api/relays endpoint - #[oai(path = "/relays", method = "get")] - Call GetAllRelaysUseCase, map to RelayDto - **File**: src/presentation/api/relay_api.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T042** [US1] [TDD] Write contract tests for POST /api/relays/{id}/toggle +- [ ] **T050** [US1] [TDD] Write contract tests for POST /api/relays/{id}/toggle - Test: Returns 200 with updated RelayDto - Test: Returns 404 for id < 1 or id > 8 - Test: State actually changes in controller - **File**: tests/contract/test_relay_api.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T043** [US1] [TDD] Implement POST /api/relays/{id}/toggle endpoint +- [ ] **T051** [US1] [TDD] Implement POST /api/relays/{id}/toggle endpoint - #[oai(path = "/relays/:id/toggle", method = "post")] - Parse id, call ToggleRelayUseCase, return updated state - **File**: src/presentation/api/relay_api.rs @@ -654,12 +781,12 @@ ### Frontend Implementation -- [ ] **T044** [P] [US1] [TDD] Create RelayDto TypeScript interface +- [ ] **T052** [P] [US1] [TDD] Create RelayDto TypeScript interface - Generate from OpenAPI spec or manually define - **File**: frontend/src/types/relay.ts - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T045** [P] [US1] [TDD] Create API client service +- [ ] **T053** [P] [US1] [TDD] Create API client service - getAllRelays(): Promise - toggleRelay(id: number): Promise - **File**: frontend/src/api/relayApi.ts @@ -816,14 +943,14 @@ --- -- [ ] **T047** [US1] [TDD] Create RelayCard component +- [ ] **T055** [US1] [TDD] Create RelayCard component - Props: relay (RelayDto) - Display relay ID, state, label - Emit toggle event on button click - **File**: frontend/src/components/RelayCard.vue - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T048** [US1] [TDD] Create RelayGrid component +- [ ] **T056** [US1] [TDD] Create RelayGrid component - Use useRelayPolling composable - Render 8 RelayCard components - Handle toggle events by calling API @@ -831,7 +958,7 @@ - **File**: frontend/src/components/RelayGrid.vue - **Complexity**: Medium | **Uncertainty**: Low -- [ ] **T049** [US1] [TDD] Integration test for US1 +- [ ] **T057** [US1] [TDD] Integration test for US1 - End-to-end test: Load page → see 8 relays → toggle relay 1 → verify state change - Use Playwright or Cypress - **File**: frontend/tests/e2e/relay-control.spec.ts @@ -847,49 +974,49 @@ **Independent Test**: POST /api/relays/all/on turns all 8 relays on -- [ ] **T050** [US2] [TDD] Write tests for BulkControlUseCase +- [ ] **T058** [US2] [TDD] Write tests for BulkControlUseCase - Test: execute(BulkOperation::AllOn) turns all relays on - Test: execute(BulkOperation::AllOff) turns all relays off - **File**: src/application/use_cases/bulk_control.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T051** [US2] [TDD] Implement BulkControlUseCase +- [ ] **T059** [US2] [TDD] Implement BulkControlUseCase - Call controller.write_all(state) - **File**: src/application/use_cases/bulk_control.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T052** [US2] [TDD] Define BulkOperation enum +- [ ] **T060** [US2] [TDD] Define BulkOperation enum - Variants: AllOn, AllOff - **File**: src/domain/relay.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T053** [US2] [TDD] Write contract tests for POST /api/relays/all/on +- [ ] **T061** [US2] [TDD] Write contract tests for POST /api/relays/all/on - Test: Returns 200, all relays turn on - **File**: tests/contract/test_relay_api.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T054** [US2] [TDD] Implement POST /api/relays/all/on endpoint +- [ ] **T062** [US2] [TDD] Implement POST /api/relays/all/on endpoint - Call BulkControlUseCase with AllOn - **File**: src/presentation/api/relay_api.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T055** [P] [US2] [TDD] Write contract tests for POST /api/relays/all/off +- [ ] **T063** [P] [US2] [TDD] Write contract tests for POST /api/relays/all/off - Test: Returns 200, all relays turn off - **File**: tests/contract/test_relay_api.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T056** [P] [US2] [TDD] Implement POST /api/relays/all/off endpoint +- [ ] **T064** [P] [US2] [TDD] Implement POST /api/relays/all/off endpoint - Call BulkControlUseCase with AllOff - **File**: src/presentation/api/relay_api.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T057** [US2] [TDD] Add bulk control buttons to frontend +- [ ] **T065** [US2] [TDD] Add bulk control buttons to frontend - Add "All On" and "All Off" buttons to RelayGrid component - Call API endpoints and refresh relay states - **File**: frontend/src/components/RelayGrid.vue - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T058** [US2] [TDD] Integration test for US2 +- [ ] **T066** [US2] [TDD] Integration test for US2 - Click "All On" → verify all 8 relays turn on - Click "All Off" → verify all 8 relays turn off - **File**: frontend/tests/e2e/bulk-control.spec.ts @@ -905,47 +1032,47 @@ **Independent Test**: GET /api/health returns health status -- [ ] **T059** [US3] [TDD] Write tests for GetHealthUseCase +- [ ] **T067** [US3] [TDD] Write tests for GetHealthUseCase - Test: Returns Healthy when controller is responsive - Test: Returns Degraded after 3 consecutive errors - Test: Returns Unhealthy after 10 consecutive errors - **File**: src/application/use_cases/get_health.rs - **Complexity**: Medium | **Uncertainty**: Low -- [ ] **T060** [US3] [TDD] Implement GetHealthUseCase +- [ ] **T068** [US3] [TDD] Implement GetHealthUseCase - Use HealthMonitor to track controller status - Return current HealthStatus - **File**: src/application/use_cases/get_health.rs - **Complexity**: Medium | **Uncertainty**: Low -- [ ] **T061** [US3] [TDD] Define HealthDto +- [ ] **T069** [US3] [TDD] Define HealthDto - Fields: status ("healthy"/"degraded"/"unhealthy"), consecutive_errors (optional), reason (optional) - **File**: src/presentation/dto/health_dto.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T062** [US3] [TDD] Write contract tests for GET /api/health +- [ ] **T070** [US3] [TDD] Write contract tests for GET /api/health - Test: Returns 200 with HealthDto - **File**: tests/contract/test_health_api.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T063** [US3] [TDD] Implement GET /api/health endpoint +- [ ] **T071** [US3] [TDD] Implement GET /api/health endpoint - Call GetHealthUseCase, map to HealthDto - **File**: src/presentation/api/health_api.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T064** [P] [US3] [TDD] Add firmware version display (optional) +- [ ] **T072** [P] [US3] [TDD] Add firmware version display (optional) - If controller supports firmware_version(), display in UI - **File**: frontend/src/components/DeviceInfo.vue - **Complexity**: Low | **Uncertainty**: Medium - **Note**: Device may not support this feature -- [ ] **T065** [US3] [TDD] Create HealthIndicator component +- [ ] **T073** [US3] [TDD] Create HealthIndicator component - Display connection status with color-coded indicator - Show firmware version if available - **File**: frontend/src/components/HealthIndicator.vue - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T066** [US3] [TDD] Integrate HealthIndicator in RelayGrid +- [ ] **T074** [US3] [TDD] Integrate HealthIndicator in RelayGrid - Fetch health status in useRelayPolling composable - Pass to HealthIndicator component - **File**: frontend/src/components/RelayGrid.vue @@ -961,37 +1088,37 @@ **Independent Test**: PUT /api/relays/{id}/label sets label, GET /api/relays returns label -- [ ] **T067** [US4] [TDD] Write tests for SetLabelUseCase +- [ ] **T075** [US4] [TDD] Write tests for SetLabelUseCase - Test: execute(RelayId(1), "Pump") sets label - Test: execute with empty label returns error - Test: execute with 51-char label returns error - **File**: src/application/use_cases/set_label.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T068** [US4] [TDD] Implement SetLabelUseCase +- [ ] **T076** [US4] [TDD] Implement SetLabelUseCase - Validate label with RelayLabel::new() - Call label_repository.set_label() - **File**: src/application/use_cases/set_label.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T069** [US4] [TDD] Write contract tests for PUT /api/relays/{id}/label +- [ ] **T077** [US4] [TDD] Write contract tests for PUT /api/relays/{id}/label - Test: Returns 200, label is persisted - Test: Returns 400 for invalid label - **File**: tests/contract/test_relay_api.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T070** [US4] [TDD] Implement PUT /api/relays/{id}/label endpoint +- [ ] **T078** [US4] [TDD] Implement PUT /api/relays/{id}/label endpoint - Parse id and label, call SetLabelUseCase - **File**: src/presentation/api/relay_api.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T071** [US4] [TDD] Add label editing to RelayCard component +- [ ] **T079** [US4] [TDD] Add label editing to RelayCard component - Click label → show input field - Submit → call PUT /api/relays/{id}/label - **File**: frontend/src/components/RelayCard.vue - **Complexity**: Medium | **Uncertainty**: Low -- [ ] **T072** [US4] [TDD] Integration test for US4 +- [ ] **T080** [US4] [TDD] Integration test for US4 - Set label for relay 1 → refresh → verify label persists - **File**: frontend/tests/e2e/relay-labeling.spec.ts - **Complexity**: Low | **Uncertainty**: Low @@ -1004,56 +1131,56 @@ **Purpose**: Testing, documentation, and production readiness -- [ ] **T073** [P] Add comprehensive logging at all architectural boundaries +- [ ] **T081** [P] Add comprehensive logging at all architectural boundaries - Log all API requests/responses - Log all Modbus operations - Log health status transitions - **Files**: All API and infrastructure modules - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T074** [P] Add OpenAPI documentation for all endpoints +- [ ] **T082** [P] Add OpenAPI documentation for all endpoints - Document request/response schemas - Add example values - Tag endpoints appropriately - **File**: src/presentation/api/*.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T075** [P] Run cargo clippy and fix all warnings +- [ ] **T083** [P] Run cargo clippy and fix all warnings - Ensure compliance with strict linting - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T076** [P] Run cargo fmt and format all code +- [ ] **T084** [P] Run cargo fmt and format all code - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T077** Generate test coverage report +- [ ] **T085** Generate test coverage report - Run: just coverage - Ensure > 80% coverage for domain and application layers - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T078** [P] Run cargo audit for dependency vulnerabilities +- [ ] **T086** [P] Run cargo audit for dependency vulnerabilities - Fix any high/critical vulnerabilities - **Complexity**: Low | **Uncertainty**: Medium -- [ ] **T079** [P] Update README.md with deployment instructions +- [ ] **T087** [P] Update README.md with deployment instructions - Document environment variables - Document Modbus device configuration - Add quickstart guide - **File**: README.md - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T080** [P] Create Docker image for backend +- [ ] **T088** [P] Create Docker image for backend - Multi-stage build with Rust - Include SQLite database setup - **File**: Dockerfile - **Complexity**: Medium | **Uncertainty**: Low -- [ ] **T081** [P] Create production settings/production.yaml +- [ ] **T089** [P] Create production settings/production.yaml - Configure for actual device IP - Set appropriate timeouts and retry settings - **File**: settings/production.yaml - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T082** Deploy to production environment +- [ ] **T090** Deploy to production environment - Test with actual Modbus relay device - Verify all user stories work end-to-end - **Complexity**: Medium | **Uncertainty**: High