Skip to content

Add WebSocket event filters for focused realtime subscribers #205

Description

@Justinabox

Motivation

The authenticated /ws realtime feed now ships a PII-safe stream for every supported public event type. That is a good default for smoke checks, but long-running integrations often need only one slice of the feed: an MFA worker may only need sms.received, a dashboard widget may only need signal.quality, and a PBX status panel may only need call.state/modem.state. Without server-side event filtering, each client receives unrelated sanitized envelopes and must discard them locally, which wastes queue capacity and makes overflow behavior harder to reason about on small Raspberry Pi deployments.

This is a narrow follow-up to the shipped WebSocket feed, not a raw-event or durable-replay feature.

User journey

  1. An operator starts Callstack with the normal authenticated HTTP server.
  2. A client connects to /ws?events=sms.received,sms.delivery_report with its bearer token.
  3. The server sends the normal hello envelope, plus the selected event names so the client can verify the subscription.
  4. The client receives only those event types; unrelated signal.quality, call, modem, DTMF, and USSD envelopes are not queued for that socket.
  5. If the query contains an unsupported event name, the handshake fails with a 400-style WebSocket HTTP response before subscribing any handlers.

API / UX sketch

  • Keep /ws unchanged when no query is supplied: subscribe to every name in SUPPORTED_WEBSOCKET_EVENTS.
  • Add an optional comma-separated query parameter:
GET /ws?events=sms.received,sms.delivery_report
  • Invalid examples should fail closed and mention supported event names without leaking private data:
GET /ws?events=sms.received,raw.at
  • The hello envelope should remain source-derived from the runtime constants and include both supported and selected events, for example:
{
  "type": "hello",
  "version": 1,
  "events": ["sms.received", "sms.delivery_report", "sms.sent", "call.state", "call.ring", "call.caller_id", "call.dtmf", "modem.state", "signal.quality", "ussd.response"],
  "selected_events": ["sms.received", "sms.delivery_report"]
}

Technical approach

  • Add a small parser near the existing WebSocket constants in server.py, for example _selected_websocket_event_specs(request).
  • Reuse _WEBSOCKET_EVENT_SPECS / SUPPORTED_WEBSOCKET_EVENTS; do not duplicate the event-name list in tests or docs.
  • Normalize query values by trimming whitespace and dropping empty comma segments.
  • Reject any unknown event name before ws.prepare(request) so unauthorized or malformed subscriptions do not attach handlers.
  • Subscribe/unsubscribe only the selected event classes for that client.
  • Keep the existing _enqueue_websocket_envelope() bounded-queue behavior and PII-safe serialize_event() envelopes unchanged.
  • If several typed events share one public name (ModemDisconnectedEvent and ModemReconnectedEvent both map to modem.state), selecting that public name should subscribe to all matching typed event classes.

Affected modules and tests

  • server.py
    • _WEBSOCKET_EVENT_SPECS
    • SUPPORTED_WEBSOCKET_EVENTS
    • websocket_feed() inside create_app()
  • tests/test_websocket.py
    • Add filter happy-path coverage: requested SMS events are delivered, unrelated signal/call events are not delivered.
    • Add duplicate/whitespace normalization coverage.
    • Add invalid event-name coverage that asserts the connection is rejected and no stale subscription receives later events.
    • Keep the existing privacy and overflow tests green.
  • README.md / ROADMAP.md docs update within the implementation PR: document the optional events= query only after tests derive examples from SUPPORTED_WEBSOCKET_EVENTS or otherwise guard against drift.

Hardware / modem caveats

  • No real modem is required; this should be tested entirely with EventBus + fake modem fixtures.
  • This must not add raw AT, raw SMS body, full caller ID, SIM identifier, IMEI/IMSI/ICCID, webhook URL, or API-key exposure.
  • This does not change modem probing, URC parsing, delivery-report collection, SMS storage, or command timing.

Acceptance criteria

  • /ws with no events query preserves current behavior and existing tests pass.
  • /ws?events=sms.received,sms.delivery_report receives only those event types and does not enqueue unrelated supported events for that client.
  • /ws?events=modem.state receives both disconnect and reconnect state envelopes.
  • Duplicate and whitespace-heavy queries are normalized deterministically in selected_events.
  • Unsupported event names are rejected before handler subscription and the response body lists supported public event names only.
  • The hello envelope includes selected_events while retaining the existing supported events list.
  • No test or docs example hard-codes a stale event list that can drift from SUPPORTED_WEBSOCKET_EVENTS.

Verification gates

git diff --check
PYTHONPATH=. uv run --no-project --with pytest --with pytest-asyncio --with pytest-aiohttp --with pyserial-asyncio --with aiosqlite pytest tests/test_websocket.py -q
PYTHONPATH=. uv run --no-project --with pytest --with pytest-asyncio --with pytest-aiohttp --with pyserial-asyncio --with aiosqlite pytest tests/ -q

Non-goals

  • Durable replay, cursors, persisted event logs, or dashboard UI.
  • Privileged raw event streams.
  • Per-sender or per-recipient filters.
  • Changing auth/rate-limit policy for WebSocket connections.
  • Adding hardware-dependent modem tests.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions