Skip to content

feat(aggregator): alert throat — sentinel incident intake, Supabase ledger, Web Push delivery + ack #3676

Closed

Description

@dezren39

Build the alert throat (Drew approved 2026-11-01, main chat): every sentinel posts incidents to ONE intake; one engine owns dedupe, delivery, escalation, and ack; every transition lands in a durable ledger.

Drew's amendments to the original design (#3450):

Already done:

  • Supabase alerts schema (above) in muse-dev2.
  • VAPID keypair generated; private key in Bitwarden Secrets Manager as pm-aggregator.vapid-private-key; public key ships in the page/worker. VAPID subject: mailto:drewrypope@gmail.com (design Q4 default).

Scope:

  1. PWA installability per feat(aggregator): PWA installability — manifest, icons, install affordance (#1254 slice a) #3315 (manifest, icons, meta; PM_AGG_PUBLIC_ORIGIN defaulting to the Tailscale Serve HTTPS origin — verify serve 443→4870 on the laptop).
  2. Real service worker per feat(aggregator): Web Push plumbing — VAPID, subscription store + API, service worker (#1254 slice b) #3316 with push + notificationclick handlers and eviction-on-activate for foreign registrations (design §6 fallback path; no probe data exists yet), plus /api/sw-probe reporting.
  3. Subscription API backed by Supabase alerts.push_subscriptions (per-class map + quiet prefs round-trip; 404/410 gone-count pruning).
  4. Incident intake POST /api/alerts (bearer token in Bitwarden as pm-aggregator.alerts-intake-token): {source, key, severity, title, body}; dedupe on (source, key); every post writes incident + incident_events rows.
  5. Engine tick: new critical incident → Web Push to subscriptions with the incident class on, delivered_at + send log; unacked critical after the ack window (config, default 15 min) → exactly one escalation push. Incident class delivers immediately (it is the breakthrough class — the sentinels are external, so the feat(aggregator): notification engine — class triggers, dedupe, quiet hours, escalation (#1254 slice c) #3317 §2 "engine is down" reasoning does not apply); dashboard classes keep the design's quiet-hours rules.
  6. /alerts page: open incidents, ack (exactly once; second ack is a no-op) and close actions, history from Supabase. Push payload deep-links to the incident on this page.
  7. A scripts/post-alert.ts CLI in the aggregator for sources to post incidents (first wired source: the Globalping watch failure path; second: manual seed for acceptance).

Acceptance: subscription round-trips through Supabase; a seeded critical pushes to a real subscription; a duplicate post sends no second push; escalation fires exactly once inside a shortened test window; ack stops the ladder and is exactly-once; all transitions present in alerts.incident_events; no secret material in repo, store, or logs.

Acceptance criteria

Activity

  1. developing-today commented on Nov 1, 2026

    @developing-today
    Contributor
  2. added
    effort/lLarge: multi-day or open-ended; expect staged check-ins
    app/aggregatorPackage/app: aggregator (pm-stack in this repo)
    meta/enrichedIntake review done: acceptance criteria, links, and normalized labels added
    on Nov 1, 2026
  3. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    Intake review — fleet monitor (2026-11-01)

    Related: design discussion in #3450; delivery design #1254 / PR #3314; build prerequisites #3315 (PWA installability), #3316 (service worker + push plumbing), follow-on classes in #3317. Ledger schema alerts already exists in Supabase muse-dev2 per the issue body.

    Notes: this is a large multi-part epic-style build (effort/l, priority/high already set). The scope list in the body is the spec; acceptance criteria appended above are drawn from it.

    Risks: Web Push subscription churn (404/410 pruning) and foreign service-worker registrations are the known sharp edges — both are named in the scope. VAPID private key and the intake bearer token live in Bitwarden Secrets Manager; neither belongs in code or comments.

    Suggested approach: land in scope order (installability → service worker → subscription API → intake → /alerts page), parking the queue task at each verified stage so the ledger work can be reviewed independently.

  4. added a commit that references this issue on Nov 1, 2026
  5. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    Built, deployed, and verified end-to-end (2026-11-01). PRs: #3680 (the alert throat), plus #3679 — a prerequisite emergency fix: main itself did not boot (isUnmatchedCharge lost its closing brace in the #2445 merge; the aggregator only survived because the running process predated it).

    Deployed: primary clone at the merge of #3680, pm-aggregator restarted (a bun install in the primary clone was needed for the new web-push dependency — worth adding to the deploy checklist for dependency-changing PRs). Tailscale Serve verified: https://amd-6.raptor-pumpkinseed.ts.net (443) → 127.0.0.1:4870, unchanged.

    Acceptance evidence (test incident 97d7d6a4-5c35-47a4-8283-530a17542329, source acceptance-3676, against the production service + the real Supabase ledger):

    • Manifest served at the HTTPS origin (start_url /attention, standalone, icons incl. maskable); /sw.js at the origin is the real worker (push + notificationclick + §6-fallback cache purge on activate; the probe script also unregisters foreign registrations page-side). Tokenless intake → 401.
    • Subscription round-trip: POST create (201) → row present in alerts.push_subscriptions with the exact per-class map + quiet prefs → DELETE → row gone, repeat DELETE 404s.
    • Delivery: seeded a critical via scripts/post-alert.ts; the engine tick pushed it — the receiving endpoint logged an RFC 8030 POST with VAPID authorization, content-encoding: aes128gcm, TTL 3600, and alerts.notification_sends recorded outcome=sent with delivered_at set.
    • Duplicate post of the same (source, key): created:false, occurrences 1→2, no second push (sends count unchanged), duplicate_suppressed event written.
    • Escalation with ALERT_ACK_WINDOW_MIN=1 (temporarily, via the user-manager environment; removed and the service restarted on defaults afterward): exactly one escalation push, escalated_at set, no third send across many later ticks.
    • Ack: first ack transitioned (status acked, sends stamped acted_on_at), second ack returned transitioned:false and wrote nothing. Close transitioned once.
    • Full ledger sequence for the incident: opened -> delivered -> duplicate_suppressed -> escalated -> acked -> closed.
    • Test suite: 870 pass / 0 fail on the branch (13 new alert-helper tests; §6 worker test and manifest test updated/added; curation + a11y tables brought along for the new page). The intake token was generated on the laptop and stored in Bitwarden as pm-aggregator.alerts-intake-token; the VAPID private key is fetched by name at runtime — no secret material in repo, store, or logs.

    One step remains, and it is a phone step, not code: a vendor push subscription (FCM/Apple/Mozilla registration) could not be created from any laptop browser — Helium registers the worker and takes the permission grant but its push registration fails ("push service error", de-Googled build with no FCM client), and the stock Chromium/Brave sessions wedged on new-tab navigation in this desktop session. So delivery was proven against a standards-compliant local push endpoint (real P-256 subscription keys, real VAPID + aes128gcm on the wire) instead of a phone. To finish: on Drew's phone, open the origin, install the PWA (required for iOS push), open /alerts, tap Enable push on this browser — the subscription lands in alerts.push_subscriptions and every downstream behavior is the proven path above.

    Also landed here: #3315's installability and the core of #3316's plumbing (those issues stay open for their remaining acceptance items — #3316's branch-B Access deep-link check is still gated on #1182). First wired source per scope item 7 is the CLI itself; pointing the Globalping watch's failure path at post-alert.ts is the follow-up.

  6. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    Push UX fix shipped — PR #3707, merged 2026-11-01T13:48:55Z, deployed (primary ff + pm-aggregator restart, healthy).

    Drew's phone report, six defects → fixes:

    1. Unsupported browser: the dead-end "Web Push is not supported…" line is gone. The panel now shows plain steps, variant by UA: iPhone → Safari → Share → Add to Home Screen → open from the icon → Enable push; in-app/other browser → open the address in the real browser. Installability verified live: manifest display: standalone, 192/512 + maskable icons, apple-touch-icon + apple-mobile-web-app-capable all served at the tailnet origin.
    2. "notification permission: default" error: root cause was push-client throwing notification permission: <raw value>, alert()ed by the page. The client now throws coded errors only; the page renders plain words per state (blocked → device-settings steps; prompt dismissed → tap again and choose Allow). The permission request still runs inside the Enable tap, before subscribing.
    3. Quiet hours OFF: a checkbox stores enabled:false in the existing quiet jsonb (no migration); inQuietHours returns false when disabled; time inputs disable while off.
    4. Plain language everywhere: "Incident alerts — a watchdog reports something down. Always delivered immediately." / "Quiet hours — pause non-urgent notifications overnight… Incident alerts always come through immediately, quiet hours or not." Bare "quiet" is gone from panel and subscription rows. The panel also opens with this browser's saved preferences applied.
    5. Test button: "Send a test alert" → POST /api/push/test sends a real push (class test) to all subscriptions + alerts.notification_sends rows; plain-words result on the page.
    6. Navigation: /alerts is primary-tier (Drew's ruling supersedes the post-dashboard: clean up the page list (12 registered pages, overlaps to resolve) #1497 dropdown placement; curation test records it) and /attention links to it.

    Acceptance evidence (production, after deploy):

    • Full suite 882 pass / 0 fail; new tests pin quiet-off merge + inQuietHours disabled, the test payload, and sendTestPush sends-row recording incl. the zero-subscription case.
    • Tailnet origin: manifest 200 (standalone, icons incl. maskable), /alerts 200 with all new controls + guidance, old dead-end text absent, Alerts present in the primary nav, icons 200.
    • POST /api/push/test on production → {subscriptions: 1, sent: 1, gone: 0, errors: 0} against Drew's live phone subscription; ledger row class=test, outcome=sent at 2026-11-01T13:49:52Z. (Pre-merge, the same path was proven against the /tmp/mockpush RFC 8030 receiver: VAPID + aes128gcm POST logged, sent row at 13:48:13Z; the zero-subscription response is all-zeros, which the page renders as "No subscriptions yet — enable push first".)
    • Drew's existing subscription was preserved through the scratch testing (endpoint/keys/classes byte-identical; quiet only gained enabled: true, which absent already meant).
  7. added a commit that references this issue on Nov 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    app/aggregatorPackage/app: aggregator (pm-stack in this repo)effort/lLarge: multi-day or open-ended; expect staged check-inskind/enhancementmeta/enrichedIntake review done: acceptance criteria, links, and normalized labels addedpriority/highImportant and time-sensitivestatus:done

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions