vigilar/docs/superpowers/specs/2026-04-03-foundation-plumbing-design.md
Aaron D. Lee c9904648fa Add foundation plumbing design spec (F1-F4)
Notification delivery, recording playback/encryption, HLS.js bundle,
and PIN verification with recovery passphrase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:38:29 -04:00

163 lines
7.9 KiB
Markdown

# Foundation Plumbing — Design Spec
## Overview
Complete four foundational features that are configured/stubbed but not yet implemented: notification delivery, recording playback, HLS.js library, and PIN verification. These are prerequisites for many other planned features.
## F1: Notification Delivery
### Web Push Sender
The infrastructure is mostly in place: `pywebpush` is a dependency, push subscriptions are stored in the `push_subscriptions` table, `insert_alert_log` exists, and the service worker (`sw.js`) already handles incoming push events with notification display.
**New file: `vigilar/alerts/sender.py`**
`send_alert(engine, event_type, severity, source_id, payload)`:
1. Determine notification content from event type and payload (title, body, icon, URL)
2. Query all active push subscriptions from DB
3. For each subscription, call `pywebpush.webpush()` with VAPID credentials
4. Log each send attempt to `alert_log` table (channel=WEB_PUSH, status=SENT|FAILED, error if any)
5. Remove subscriptions that return 410 Gone (expired)
VAPID keys: read from env var `VIGILAR_VAPID_PRIVATE_KEY` (primary), falling back to file at `StorageConfig.vapid_private_key_path` if env var is not set.
**Notification content mapping:**
| Event Type | Title | Body Example |
|-----------|-------|-------------|
| PERSON_DETECTED | Person Detected | "Person on Front Entrance" |
| PET_ESCAPE | Pet Alert | "Angel detected on Back Deck (exterior)" |
| UNKNOWN_ANIMAL | Unknown Animal | "Unknown cat on Front Entrance" |
| WILDLIFE_PREDATOR | Wildlife Alert | "Bear detected — Front Entrance" |
| WILDLIFE_NUISANCE | Wildlife | "Raccoon on Back Deck" |
| UNKNOWN_VEHICLE_DETECTED | Unknown Vehicle | "Unknown vehicle on Front Entrance" |
| POWER_LOSS | Power Alert | "UPS on battery" |
| LOW_BATTERY | Battery Critical | "UPS battery low" |
**Integration point:** In `EventProcessor._execute_action`, when action is `alert_all` or `push_and_record`, call `send_alert()` instead of (or in addition to) just publishing to MQTT. The `push_adults` action filters subscriptions by a `role` field (future — for now, send to all subscriptions for any push action).
### Audit Log
Every alert-worthy event is already written to the `events` table. The `alert_log` table tracks notification delivery attempts. Additionally:
- Configure a dedicated Python logger `vigilar.alerts` that writes to syslog via `logging.handlers.SysLogHandler`
- Log format: `[{severity}] {event_type} on {source_id}: {summary}`
- Logger is configured in `main.py` at startup, using the system's syslog socket (`/dev/log` on Linux)
- This is a logging config addition, not a new module
## F2: Recording Playback & Download
### Current State
- Recordings are written as H.264 MP4 by FFmpeg via `AdaptiveRecorder`
- The `recordings` table has `encrypted` column (defaults to 1) and `file_path`
- `StorageConfig.encrypt_recordings` defaults to True, `key_file` points to `/etc/vigilar/secrets/storage.key`
- **No encryption is actually implemented yet** — files are written as plain MP4 despite the schema flag
### Design
Since encryption isn't implemented in the recorder yet, implement it end-to-end:
**Encryption (recorder side):**
- After FFmpeg finishes writing an MP4 segment, encrypt the file using AES-256-CTR with the key from `VIGILAR_ENCRYPTION_KEY` env var
- Use `cryptography.hazmat.primitives.ciphers` (AES-CTR) for streaming-friendly encryption (no need to buffer entire file)
- Write encrypted file with `.vge` extension (Vigilar encrypted), delete the plain MP4
- Store a random 16-byte IV as the first 16 bytes of the `.vge` file
- If `VIGILAR_ENCRYPTION_KEY` is not set and `encrypt_recordings` is True, log a warning and write plain MP4 (graceful degradation)
**Key format:** 32-byte hex string in env var (64 hex characters). Decoded to 32 bytes for AES-256.
**Decryption (playback side):**
- `GET /recordings/<id>/download` reads the `.vge` file, extracts the IV from the first 16 bytes, decrypts with AES-256-CTR, streams as `video/mp4`
- Response uses `Transfer-Encoding: chunked` to avoid buffering the entire file in memory
- If the file is plain MP4 (not encrypted or `encrypted=0` in DB), stream directly
**Recording list API:**
- `GET /recordings/api/list` queries the `recordings` table with optional filters: `camera_id`, `date`, `detection_type`, `starred`
- Returns JSON array of recording metadata (id, camera_id, started_at, ended_at, duration_s, detection_type, starred, file_size)
**Recording delete:**
- `DELETE /recordings/<id>` removes the DB row and unlinks the file from disk
- Returns 404 if recording not found
### Playback UI Integration
The existing timeline component (`timeline.js`) renders clickable segments. Wire click events to load the recording in an HTML5 `<video>` element with the download URL as source. The browser handles MP4 playback natively.
## F3: HLS.js Bundle
Download `hls.js` v1.5.x stable release (minified, ~200KB), commit to `vigilar/web/static/js/hls.min.js`, replacing the current placeholder that just logs to console.
The existing camera grid template already has the `<script>` tag and initialization code that expects `Hls` to be globally available. No template changes needed — just the library file.
## F4: PIN Verification
### PIN Storage
Two secrets stored as hashes in `vigilar.toml`:
```toml
[security]
pin_hash = "" # bcrypt hash of household PIN
recovery_passphrase_hash = "" # bcrypt hash of recovery passphrase
```
Hashed with `hashlib.pbkdf2_hmac` (SHA-256) — stdlib, no new dependency, sufficient for a local-only system. Store as `pbkdf2_sha256$<hex_salt>$<hex_hash>` string. 16-byte random salt per hash.
### Config Model Addition
```python
class SecurityConfig(BaseModel):
pin_hash: str = ""
recovery_passphrase_hash: str = ""
```
Add to `VigilarConfig` as `security: SecurityConfig`.
### PIN Setup Flow
If `pin_hash` is empty (first run), the system is "unprotected" — arm/disarm works without PIN. The settings UI provides a "Set PIN" form that:
1. Accepts new PIN + recovery passphrase
2. Hashes both
3. Writes hashes to config via the existing `config_writer.py`
### Endpoint Changes
**`POST /system/api/arm`** — Verify `pin` from request body against `pin_hash`. Return 401 if wrong. If `pin_hash` is empty, accept without PIN (backward compatible).
**`POST /system/api/disarm`** — Same PIN check.
**`POST /system/api/reset-pin`** — Accepts `recovery_passphrase` + `new_pin`. Verify passphrase against `recovery_passphrase_hash`. If valid, hash and store new `pin_hash`. Return 401 if passphrase wrong.
### Audit Logging
All arm/disarm attempts (success and failure) written to `arm_state_log` table (already exists with `pin_hash` column). Failed attempts logged with the action attempted but NOT the submitted PIN value.
## Dependencies
### New Packages
None — all implementation uses existing dependencies (`cryptography`, `pywebpush`, `py-vapid`) and stdlib (`hashlib`, `logging.handlers`).
### New Files
| File | Purpose |
|------|---------|
| `vigilar/alerts/sender.py` | Web Push notification sender + audit logging |
| `vigilar/web/static/js/hls.min.js` | HLS.js library (replace placeholder) |
### Modified Files
| File | Changes |
|------|---------|
| `vigilar/events/processor.py` | Call `send_alert()` from `_execute_action` |
| `vigilar/camera/recorder.py` | Add AES-256-CTR encryption after recording |
| `vigilar/web/blueprints/recordings.py` | Implement list, download (decrypt), delete |
| `vigilar/web/blueprints/system.py` | PIN verification on arm/disarm, reset-pin endpoint |
| `vigilar/config.py` | Add SecurityConfig model |
| `vigilar/main.py` | Configure syslog handler for alerts logger |
## Out of Scope
- Email and webhook notification channels (future)
- Rate limiting on PIN attempts / lockout (YAGNI for local system)
- Push subscription management UI (subscribe/unsubscribe) — the service worker and `push.js` already handle this
- Recording thumbnail generation (already has `thumbnail_path` column — separate feature)