Skip to content

feat: add Web Push notification provider (go_notify_yourself v0.3.0) - #1340

Merged
Wikid82 merged 16 commits into
developmentfrom
feature/notifications-webpush-provider
Sep 15, 2026
Merged

Wikid82 merged 16 commits into
developmentfrom
feature/notifications-webpush-provider

Conversation

@Wikid82

@Wikid82 Wikid82 commented Sep 15, 2026

Copy link
Copy Markdown
Owner

Summary

  • Adds Web Push as a new notification-provider type, sourced from go_notify_yourself v0.3.0's new providers/webpush package (VAPID/RFC 8291/8292 Web Push).
  • Resolves the one-to-many mismatch between Charon's existing one-row-per-destination provider model and Web Push's one-VAPID-identity-to-many-browser-subscriptions shape: a single singleton NotificationProvider row (type="webpush", DB-enforced via a partial unique index) holds the VAPID application identity, with a new WebPushSubscription table holding each browser/device destination. Dispatch fans out one webpush.Client per subscription, auto-pruning subscriptions a push service reports as 404/410 Gone.
  • Self-service: any authenticated user with management access can subscribe/unsubscribe their own device and read the VAPID public key. Provisioning the singleton provider, and toggling the security-event forwarding preferences on it, remain admin-only (existing admin gate on the provider-update endpoint, confirmed and pinned with a regression test — no new authz code needed there).
  • Went through the full planning pipeline: Planning agent spec → Supervisor review → a user decision on auth scoping → spec revision addressing both required review findings (the singleton-creation race, closed with a DB-level partial unique index; and a documented non-blocking risk note on the VAPID private key's plaintext-at-rest storage, consistent with all 8 other provider types' token storage today).

Commits

  1. test: add e2e specs for web push subscribe/unsubscribe flow (fixme)
  2. chore: bump go_notify_yourself to v0.3.0
  3. feat: add WebPushSubscription model, migration, and singleton index
  4. feat: wire webpush into notify provider allowlist
  5. feat: add web push dispatch fan-out and subscription pruning
  6. feat: add web push provisioning and subscription API endpoints
  7. feat: add web push service worker and subscribe/unsubscribe UI
  8. docs: update web push spec with review resolutions
  9. docs: document web push notification provider
  10. test: enable web push e2e specs

Test plan

  • go build ./... and npm run build succeed
  • Backend unit tests pass (go test ./internal/models/... ./internal/services/... ./internal/api/handlers/...), including a concurrency test proving the singleton partial-unique-index closes the create race, and a regression test proving RoleUser gets 403 setting security-forward toggles on a webpush provider row
  • Frontend unit tests pass (3388 passed, 4 skipped, 2 todo)
  • ./scripts/scan-gorm-security.sh --check — zero CRITICAL/HIGH
  • scripts/go-test-coverage.sh — 88.3% line coverage (≥87% gate)
  • scripts/frontend-test-coverage.sh — 90.95% line coverage (≥87% gate)
  • npm run type-check clean
  • Targeted E2E: npx playwright test tests/e2e/notifications-webpush.spec.ts --project=firefox — 7/7 passed
  • Manual end-to-end push delivery to a real browser (documented as required manual verification in the spec — CI cannot receive a real browser push)

Full technical spec: docs/plans/current_spec.md.

Pulls in the providers/webpush package (RFC 8030/8291/8292 direct
browser Web Push, no third-party relay) needed for the upcoming Web
Push notification provider. go.sum diff reviewed: only the version
line for this module changes, no new transitive dependencies.

Claude-Session: https://claude.ai/code/session_01YMic1SfEixL9Ej3RvnDLFN
Encodes the Web Push notification provider's intended behavior as
test.fixme specs against the approved spec's API contracts (§3.4) and
frontend design (§3.6), before any backend/frontend implementation
lands. Covers admin-only provisioning, per-device subscribe/unsubscribe
(with the browser Push API stubbed via page.addInitScript, since real
push delivery cannot be exercised in CI), and per-event-type toggle
regression for the new provider type.

Claude-Session: https://claude.ai/code/session_01YMic1SfEixL9Ej3RvnDLFN
Adds the child table holding each browser's Web Push destination
(FK to NotificationProvider, Type="webpush"), registers it in
AutoMigrate, and creates the idx_webpush_singleton partial unique
index (CREATE UNIQUE INDEX ... WHERE type='webpush') immediately
after AutoMigrate to close a race condition where two concurrent
provisioning requests could otherwise create two independent VAPID
identities (service-layer COUNT-then-INSERT alone is not atomic
under SQLite's single-writer connection pool).

Includes a concurrency regression test firing two simultaneous
INSERTs against the index and asserting exactly one succeeds.

Claude-Session: https://claude.ai/code/session_01YMic1SfEixL9Ej3RvnDLFN
Adds "webpush" to isSupportedNotificationProviderType and
supportsJSONTemplates (its plaintext payload is JSON, same
providers/internal/render convention as every other JSON-template
type), a new FlagWebPushServiceEnabled dispatch-enabled flag, the
providers/webpush blank import for registry self-registration, and
"webpush" in the registry consistency test's supportedTypes slice.

Claude-Session: https://claude.ai/code/session_01YMic1SfEixL9Ej3RvnDLFN
Adds dispatchWebPushViaNotify (notify_webpush_adapter.go): fans a
single logical notification out to every WebPushSubscription row
under a webpush provider, one webpush.Client per row, sequentially
within the provider's already-backgrounded dispatch goroutine to
bound outbound concurrency.

extractHTTPStatusFromNotifyError regex-parses the numeric HTTP
status out of transport.Wrapper's plain formatted error string
(go_notify_yourself exposes no typed status error) to detect the
standard Web Push 404/410 "subscription is gone" signal. A
subscription reported gone is pruned immediately; any other failure
increments FailureCount and prunes at
webpushMaxConsecutiveFailures (10) to bound the table without
nuking a subscription on one transient failure.

Wires the new "webpush" branch into SendExternal's dispatch loop,
parallel to the existing "email" special case. New file is at 100%
statement coverage.

Claude-Session: https://claude.ai/code/session_01YMic1SfEixL9Ej3RvnDLFN
Adds WebPushHandler (Provision, VAPIDPublicKey, Subscribe,
ListSubscriptions, Unsubscribe) and the matching NotificationService
methods (ProvisionWebPush, GetWebPushVAPIDPublicKey,
RegisterWebPushSubscription, ListWebPushSubscriptionsForUser,
DeleteWebPushSubscription), wired under the management route group.
Provisioning is admin-only (RequireRole(admin), mirroring the
Test/Preview precedent); VAPID key read, subscribe, list, and
unsubscribe are self-service for any management-access user
(§3.4.0).

ProvisionWebPush's cheap COUNT fast-path is backstopped by the
idx_webpush_singleton partial unique index added in a prior commit;
a losing CreateProvider INSERT is caught and mapped to 409, never a
raw 500, using this codebase's existing
errors.Is(gorm.ErrDuplicatedKey)/"UNIQUE constraint failed"
detection idiom.

Also fixes a token-wiping bug the new type surfaced: CreateProvider
and UpdateProvider's token-preserving allowlists (and the generic
Update handler's provider-type allowlist) didn't include "webpush",
which would have silently erased the VAPID private key on create/
update. Not in the original commit-6 file list, but required for
ProvisionWebPush and any admin edit of a webpush provider's
preferences to work at all.

Includes the two required regression tests: concurrent Provision
requests never surface a raw 500 (only one 201, the loser 409), and
a RoleUser caller gets 403 from the existing generic
PUT /notifications/providers/:id when setting a NotifySecurityXxx
field on a webpush row (pins already-existing admin-gate behavior,
no new authz code). Also updates
TestManagementGroup_MutationsAreAdminGuarded's userOKMutationAllowlist
for the two intentionally self-service webpush routes.

Claude-Session: https://claude.ai/code/session_01YMic1SfEixL9Ej3RvnDLFN
Adds the frontend half of Web Push notifications: a minimal service
worker (push/notificationclick), a base64url-to-Uint8Array helper for
the VAPID public key, typed API client functions for the five backend
webpush endpoints, and Notifications page UI for admin-only
provisioning, per-device subscribe/unsubscribe, and the
not-provisioned/unsupported-browser states.

Claude-Session: https://claude.ai/code/session_01YMic1SfEixL9Ej3RvnDLFN
Reflects the supervisor review pass: DB-level singleton enforcement
via partial unique index, the resolved auth-scoping decision, and a
documented (non-blocking) risk note on VAPID key plaintext storage.

Claude-Session: https://claude.ai/code/session_01YMic1SfEixL9Ej3RvnDLFN
Adds Web Push to the notification-provider list and feature summary,
walks through admin provisioning and per-device subscribing in
docs/features/notifications.md, and notes the new WebPushSubscription
model and provider entry in ARCHITECTURE.md.

Claude-Session: https://claude.ai/code/session_01YMic1SfEixL9Ej3RvnDLFN
Flip the Web Push provider E2E specs from test.fixme to live tests now
that the backend/frontend implementation has landed, and adapt them to
the actual UI: provisioning and device subscribe/unsubscribe happen
through a dedicated Web Push card rather than the generic Add Provider
form, and the device toggle is a subscribe button plus a per-row
remove button rather than a single on/off control.

Claude-Session: https://claude.ai/code/session_01YMic1SfEixL9Ej3RvnDLFN
@codecov

codecov Bot commented Sep 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.75000% with 25 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...kend/internal/services/webpush_provider_service.go 87.50% 15 Missing ⚠️
frontend/src/pages/Notifications.tsx 88.63% 0 Missing and 10 partials ⚠️

📢 Thoughts on this report? Let us know!

@github-advanced-security

Copy link
Copy Markdown
Contributor

You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool.

What Enabling Code Scanning Means:

  • The 'Security' tab will display more code scanning analysis results (e.g., for the default branch).
  • Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results.
  • You will be able to see the analysis results for the pull request's branch on this overview once the scans have completed and the checks have passed.

For more information about GitHub Code Scanning, check out the documentation.

@github-actions

github-actions Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

✅ Supply Chain Verification Results

PASSED

📦 SBOM Summary

  • Components: 1769

🔍 Vulnerability Scan

Severity Count
🔴 Critical 0
🟠 High 0
🟡 Medium 0
🟢 Low 0
Total 0

📎 Artifacts

  • SBOM (CycloneDX JSON) and Grype results available in workflow artifacts

Generated by Supply Chain Verification workflow • View Details

Two concurrent provision requests could surface a raw 500 instead of
mapping the loser to 409: SQLite's shared-cache mode returns "database
table is locked" (SQLITE_LOCKED) on a racing INSERT, which isn't
covered by the driver's busy_timeout (that only retries SQLITE_BUSY).
Retry both the fast-path COUNT check and the INSERT on this class of
transient error, mirroring the existing retry pattern in
security_service.go and credential_service.go.
webpush_provider_service.go had no tests in its own package (only
indirect exercise via the handlers package, which Go's default
per-package coverage instrumentation doesn't attribute back to this
file), leaving it at 0% patch coverage. Add direct tests for
ProvisionWebPush, GetWebPushVAPIDPublicKey,
RegisterWebPushSubscription, ListWebPushSubscriptionsForUser,
DeleteWebPushSubscription, and the SendExternal/isDispatchEnabled
webpush dispatch branches in notification_service.go.
The notifications settings page now always renders a singleton
WebPushCard with its own "Name" field, so the pre-existing Discord
payload-contract E2E test's unscoped page.getByLabel('Name') resolved
to two elements (strict-mode violation) — the actual cause of the
Shard 3 E2E failures on all three browsers. Give ProviderForm's <form>
a data-testid and scope the assertion to it.

Also close out the frontend patch-coverage gaps: an isWebPush-editing
unit test (hides the URL field, shows the Web Push guidance note) and
several WebPushCard branch cases (empty provision name default,
subscribe-before-VAPID-loaded guard, missing PushSubscription JSON
fields, non-Error subscribe failure, subscription row with no user
agent).
@Wikid82
Wikid82 merged commit c2a8372 into development Sep 15, 2026
51 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.

2 participants