Skip to content

Web Push channel: alerts to paired phones without a third-party messenger - #177

Merged
okieselbach merged 11 commits into
mainfrom
PushRelay
Oct 8, 2026
Merged

okieselbach merged 11 commits into
mainfrom
PushRelay

Conversation

@okieselbach

Copy link
Copy Markdown
Owner

What this adds

A Web Push notification channel: alerts reach paired phones and browsers directly from the backend, end-to-end encrypted per device (RFC 8291), without a third-party messenger. Decisions D-327 to D-330.

  • Provider Push (50) in both channel scopes. The channel carries no destination; NotificationChannelDispatcher now takes a NotificationScope from every caller and the push transport resolves the scope's paired devices at send time (table-backed Admin/Operator in a tenant, GlobalAdmin with matching identity binding on the platform). The tenant-scope provider stays Global-Admin-only until the customer release, behind the same validation gate as Telegram.
  • Pairing without a sign-in on the phone. A signed-in Admin/Operator creates a one-shot code (QR or 11 characters, hashed, 10 minutes, five-failure burn); the phone redeems it anonymously; the portal session confirms the device. The phone's only credential is a device token in a request header, good for status, re-subscribe and unpair of its own row.
  • Library src/Push (BCL only, no packages): VAPID key ring with a kid on every device row, RFC 8291 aes128gcm encryption with the RFC's Appendix A vector pinned byte for byte, an SSRF-gated HTTP client and an endpoint host policy.
  • Lock-screen projection: a push carries an allow-list of facts only, never rule sections, raw payloads, failure reasons or the ops message; serial numbers masked; 2 KB cap.
  • Lifecycle: 404/410 mark a device Stale instead of deleting it (the receiver re-arms itself on open); devices pause when their owner stops signing in and resume by themselves; role loss sends a wipe push and deletes the row. PushDevices and PushPairingGrants are credential-bearing: raw-table deny-list, no backup, redacted dependency telemetry.
  • Receiver app under /push/ (service worker, Declarative Web Push shape, local history, pairing and status pages, manifest and icons) and portal UI (provider in the channel editor, pairing dialog with QR, device lists for tenant and platform scope, "Notify me when done" on a session).
  • Operations: 22 catalogued routes, ops events PushDeliveryFailed and PushEndpointRefused, maintenance sweep inside the existing run, settings template, health markers.

A second review pass (35 confirmed findings) is folded in: Global-Admin session watches fan out over the watcher's own scopes, per-device throttle on the token routes, one pause path for send time and maintenance, no Revoked state anywhere, CAS retry loop, re-arm after transport-failure pauses, de-duplicated ops events.

Verification

Suite Result
Backend (xUnit) 7371 passed (+183 new push tests)
Push library 125 passed
MCP 997 passed
Web 2061 passed, tsc 0, eslint 0
Solution build 0 warnings
zizmor on ci.yml no findings

Before the first push (operator)

  1. Push__Vapid__ActiveKey is set (generator script in the infra repo).
  2. Add /api/push to the Function App's client-certificate exclusion paths. Platform CORS already reflects the X-Push-Device-Token header; without the exclusion the push routes answer 403 from the platform before any code runs.
  3. Deploy Backend, then Web, then MCP (the provider enum changes both generated wire files). Verify the deployed version.
  4. Admin › Alerts: create the ops channel "Push to paired devices" and bind rules. Alerts › Push devices: pair a phone.
  5. Log alert on the PushDelivery custom event to the Action Group e-mail, so a push outage is not reported only over push.

Still open

  • Interop on real devices (iPhone home-screen app without a portal sign-in, Android, Windows, macOS, Firefox).
  • Trust text for the Conditional-Access consequence before any customer tenant uses the channel.
  • Key rotation remains a re-pair campaign; the receiver cannot migrate to a new VAPID key by itself.
  • Push.Tests has only run on Windows so far; production is Linux.

The branch also carries four dependency commits made on it in parallel (sprintf-js removal, sharp/proxy-addr/source-map-js bumps, validate.js replacing ajv-cli).

🤖 Generated with Claude Code

okieselbach and others added 11 commits October 7, 2026 16:34
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nger (D-327 to D-330)

Provider WebhookProviderType.Push = 50 in both channel scopes. It carries no destination;
NotificationChannelDispatcher takes a NotificationScope from every caller and the push
transport resolves the scope's paired devices at send time (table-backed Admin/Operator,
GlobalAdmin with matching identity binding; Viewer and Global Reader never).

The receiver never signs in: a signed-in Admin/Operator creates a one-shot pairing code
(11 Crockford chars, hashed, 10 min, ETag one-shot, five-failure burn), the phone redeems
it anonymously, the portal session confirms. The phone's only credential is the device
token in X-Push-Device-Token, good for status, re-subscribe and unpair of its own row
(10 calls per minute per device; the anonymous pairing routes 10 per minute per IP).

Payloads are RFC 8291 per device (new BCL-only library src/Push with the RFC vector pinned
byte for byte) with a VAPID key ring and the kid on every device row; the projection reads
an allow-list only (never Sections, DataJson, Failure Reason, Last Failure or the ops
message), serial masked, 2 KB. 404/410 mark a device Stale instead of deleting it and the
receiver re-arms it on its next open (the same PUT lifts a delivery-failures pause); devices
pause when their owner stops signing in (PushOwners stamp in auth/me) through one pause path
and resume by themselves; an owner without a stamp is paused, never deleted outright; role
loss revokes with a wipe push and deletes the row (there is no Revoked state). PushDevices
and PushPairingGrants are credential-bearing: raw-table deny-list, no backup, redacted
dependency rows.

Receiver app under /push/ (module service worker, Declarative Web Push shape, local history,
pairing and status pages, manifest + icons), portal UI (Push provider in the channel editor,
pairing dialog with QR code, device lists for tenant and platform scope, "Notify me when
done" on the session page — the watch row records the watcher's own scopes, so a Global
Admin watching a customer session is notified on the platform-paired phone), 22 catalogued
routes, ops events PushDeliveryFailed and PushEndpointRefused (deduplicated per instance),
maintenance sweep inside the existing run, settings template.

The tenant-scope provider stays Global-Admin-only until the customer release (same
ValidateModel gate as Telegram). Backend 7371, library 125, MCP 997, web 2061 tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…327 to D-330, markers, VAPID generator)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
GHSA-hp3w-g68c-fv3c (sprintf-js <= 1.1.3, denial of service through unbounded
precision specifiers) has no patched release, so both chains are cut instead.

MCP (#117): sprintf-js arrived via @huggingface/transformers -> onnxruntime-node
-> global-agent 3 -> roarr. global-agent is only loaded by onnxruntime-node's
CUDA install script, which the image never runs (npm ci --ignore-scripts). The
override global-agent ^4.1.3 drops roarr; 4.x keeps the bootstrap export that
script calls, and onnxruntime-node 1.30 depends on the same range.

Rules tooling (#114): ajv-cli 5.0.0 (no release since 2023) pulled js-yaml 3
-> argparse 1 -> sprintf-js. rules/scripts/validate.js now checks the four rule
sets against their schemas with ajv (draft 2020-12) and ajv-formats directly,
and combine-rules.yml runs it in one step instead of four ajv-cli loops. The
lockfile shrinks from 26 to 6 packages; the fast-json-patch override and the
legacy-peer-deps .npmrc (only there for ajv-cli's ts-node peer) are gone.

Verified: validate.js accepts all 149 rule files plus guardrails.json exactly
as ajv-cli did and rejects a corrupted copy (missing field, broken JSON, U+2028
in a guardrail value) with exit 1; clean npm ci + combine.js leave the
generated outputs unchanged; MCP build and 997 tests green; zizmor clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…i: B-gzv, rule-builder fallback)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Web)

- sharp 0.35.4 -> 0.35.5 (GHSA-wq5f-xc86-pv6w, librsvg): the existing sharp
  override in both projects now floors at ^0.35.5 so the fixed build cannot be
  undercut by a parent range.
- proxy-addr 2.0.7 -> 2.0.8 (GHSA-jqcg-44mw-7w3h, via express; MCP only) and
  source-map-js 1.2.1 -> 1.2.2 (GHSA-68fv-2mgg-jv7q, via postcss) are
  lockfile-only bumps inside the ranges their parents declare.

No other lockfile entry moved. npm audit in the MCP server is clean.
Verified: MCP build and 997 tests green; Web tsc clean, 2061 tests green,
next build (static export) succeeds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…requisite, EU app facts)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
16.3.8 closes GHSA-3w37-wq28-93x7, GHSA-4jqv-mc3x-m676, GHSA-39w2-rjm5-chcv,
GHSA-f87g-xv8r-7p7x, GHSA-mcj8-r9mp-w47p and GHSA-cjq9-62q9-8jv4 (affected
16.0.0 - 16.3.7). Under output:"export" the cache, ISR and image-optimization
paths never run on the SWA; the dev-server disclosure is local-only. The patch
is taken anyway. next, eslint-config-next and @next/bundle-analyzer stay
aligned and floor at ^16.3.8; the lockfile is pinned to 16.3.8, not the 16.4.0
minor the caret would resolve to.

What npm audit still reports (braces, micromatch, chokidar, fast-glob,
postcss-selector-parser) comes through Tailwind 3 and eslint-config-next,
build-time only; braces has no fix, the rest needs Tailwind 4 (backlog).

Verified: tsc clean, eslint 0 findings, 2061 tests green, next build
(16.3.8, 102 static pages) succeeds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ys on 3 (D-326)

main brought the Dependabot bumps #173-#176 (sharp, proxy-addr, source-map-js, and Tailwind 3 -> 4);
the branch had bumped the same packages by hand. Resolution per B-dzs: both sides' overrides kept
(MCP sharp ^0.35.5 + global-agent ^4.1.3; web sharp ^0.35.5 + postcss-selector-parser ^7.1.6),
tailwindcss back to ^3.4.19 (the v4 bump breaks the build, D-326), next held at 16.3.8, both
lockfiles regenerated with npm install --ignore-scripts instead of merged by hand.

Verified: web tsc 0, eslint 0, vitest 2061, next build green; MCP 997 tests, build, audit clean;
web audit shows only the Tailwind-3 / eslint-config-next chain (B-hxz).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@okieselbach
okieselbach merged commit 04fae4f into main Oct 8, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant