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

7.9 KiB

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:

[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

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)