Repository navigation
Deliver the sensor-offline alert out of band from the app (#63) - #78
Merged
Merged
Conversation
The one alert the sensor can never send is that it has gone away. Until now the app noticed after five minutes, appended a Medium alert to its own list, and told nobody: the synthetic alert never went through onAlertNotificationsRequested, so it did not even reach Notification Center, and nothing left the Mac. Now the app delivers that alert itself: - Notification Center, through the same hook WebSocket alerts use, so the banner shows with the window closed. - Slack, through the operator's configured webhook, formatted exactly as the sensor formats its own Slack messages and honoring the Slack minimum severity. The sensor redacts the webhook URL on every read, so the app caches it in its own Keychain item at the one moment it holds the real value: when the operator types it in Settings. Clearing the field removes the copy. Posting uses an ephemeral URLSession. - Severity is High when the sensor had been live earlier in this app session and Medium when it never connected. Also fixes the outage timer guard. It required exactly .disconnected, but reconnects run every 30 s, so a health check in flight at the deadline dropped the alert for the rest of the outage. .connecting now counts as offline; authFailed and incompatible still do not, since the sensor answered. APNs stays sensor-side: the app never holds relay credentials and has no UI to enter them. Docs: USER_GUIDE alert table and reconnect paragraph, a "where deliveries come from" note under Alert Methods, Credential Safety, and the SECURITY_MODEL stored-secrets section.
…e-dispatch # Conflicts: # docs/USER_GUIDE.md
chrissyrocket
approved these changes
Oct 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #63.
Why
An attacker who finds the sensor host has stopping it as the obvious first move. Today the app notices after five minutes, appends a Medium alert to its own list, and tells nobody. Two gaps, both fixed here:
onAlertNotificationsRequested, so it did not reach Notification Center at all. With the window closed, nothing showed.What
Connection service (
SensorConnectionService)system.sensor_offline, adds it to the feed, routes it through the same Notification Center hook WebSocket alerts use, then hands it to an injectedofflineDeliveryclosure..liveearlier in this app session, Medium when it never connected..disconnected. Reconnects run every 30 s, so a health check in flight at the five-minute mark silently dropped the alert for the rest of the outage.isOfflinenow treats.connectingas offline;authFailedandincompatibleare not, since the sensor answered.offlineAlertDelayis injectable (default 300 s) so the timer is testable.Out-of-band delivery (
Desktop/OfflineAlertDelivery.swift, new)SlackAlertFormattermirrors the sensor'sformat_slack_payloadblock for block (header, severity/type fields, time or source-IP section, italic detail, same emoji map), so an app-sent message is indistinguishable in the channel.SlackDeliveryCache: the sensor redactswebhook_urlon every read, so the only moment the app holds the real URL is when the operator types it in Settings. The cache stores it then, as a Keychain generic password under the app's service name; enabled and minimum severity live in defaults and refresh from every config load. The redaction sentinel never overwrites a cached URL; an emptied field deletes it; onlyhttpsURLs with a host are accepted.OfflineAlertDispatcher: honors enabled and minimum severity, POSTs through an ephemeralURLSession, never throws (the caller is a connection-loss path), and reportsdelivered/skipped(reason)/failed(message).AlertSeverityRankis shared withMacNotificationPolicyinstead of a second private copy.Settings:
saveAlertMethod("slack", …)and the Slack branch ofloadConfigboth feed the cache.App: wires
OfflineAlertDispatcher.sharedinto the connection service.Scoping decision
APNs stays sensor-side. The app never receives
relay_url/relay_token/device_token(redacted) and has no UI to enter them, so there is nothing to cache. The docs say so explicitly.Security model
One new place a configuration credential lives: the Slack webhook URL in the Mac app's Keychain, written only on operator entry, deleted on clear, used only for this alert, posted over an ephemeral session.
docs/SECURITY_MODEL.md"Stored secrets" anddocs/USER_GUIDE.md"Credential Safety" record it. Control surface only; nothing attacker-visible changes.Tests
Red first (compile failures on the new types and parameters), then green:
SlackAlertFormatterTests: block shape matches the sensor for an offline alert; emoji map; source-IP section variant.SlackDeliveryCacheTests: real URL cached; redaction sentinel ignored; disable keeps URL; empty clears; non-https rejected; empty cache.OfflineAlertDispatcherTests: posts formatted JSON to the cached webhook; below-minimum skipped; disabled and unconfigured skipped; HTTP 500 reported not thrown.OfflineAlertOutOfBandTests(connection service, 50 ms delay): never-connected outage is Medium and reaches both Notification Center and the delivery hook; losing a live sensor is High;isOfflinetable; synthetic severity.MockWSManagergainedfinishStreamAfterFramesso a test can take the service from live to disconnected.Full
swift test: 421 + 121 + 39 passed.Docs
USER_GUIDE.md: Sensor Disconnected row (Medium or High, delivered by the app), the reconnect paragraph, a "Where deliveries come from" note under Alert Methods, Credential Safety.SECURITY_MODEL.md: Stored secrets.