Elarion

Web Push

Notify users whose app is closed — VAPID keys, the subscription store, encrypted delivery with dead-subscription cleanup, and the browser/service-worker half, without a third-party push service.

Client events reach a user who has the app open. Web Push (RFC 8030, encrypted per RFC 8291, authenticated with VAPID per RFC 8292) reaches one who does not: a browser or installed PWA subscribes once, and the server can notify it later with the app closed — "your deploy failed", "time to feed the starter". It is what an installed PWA on a phone needs to be useful.

Every piece of that is framework-shaped, so Elarion owns it (ADR-0076): the VAPID key pair, the subscription store, the send loop with cleanup, the subscribe endpoints, and the browser and service-worker plumbing. Who receives a notification, when, and what it says stay in the application.

PackageRole
Elarion.WebPushIWebPushSender, WebPushSubscriptionService, VAPID keys, the store seams, in-memory defaults.
Elarion.WebPush.EntityFrameworkCoreDurable elarion_push_subscriptions and elarion_vapid_keys tables (PostgreSQL or SQLite).
Elarion.WebPush.AspNetCoreMapElarionWebPush() — HTTP bindings for the three subscription handlers your application declares.
@swimmesberger/elarion-webpushAvailability detection, permission and subscription helpers, and the service-worker module.

Wiring

// The DbContext maps the two tables:
[GenerateDbSets]
[GenerateElarionWebPush]
public partial class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options) {
    protected override void OnModelCreating(ModelBuilder modelBuilder) => ConfigureEntities(modelBuilder);
}

builder.Services.AddElarionSettingsDataProtection();   // protects the VAPID private key at rest (any ISettingValueProtector)
builder.Services.AddElarionWebPushEntityFrameworkCore<AppDbContext>(options =>
    options.Subject = "mailto:ops@example.com");   // required: the VAPID contact

app.MapElarionWebPush().RequireAuthorization();    // GET public-key, POST subscribe/unsubscribe under /webpush

plus the three handlers below, which MapElarionWebPush() dispatches to.

The EF stores write with INSERT … ON CONFLICT statements, which run on PostgreSQL and SQLite (3.24+). Their parameters are created from the model's own type mappings, so a context-wide value converter applies to them as it does to a tracked insert — a SQLite application that stores DateTimeOffset as a sortable integer keeps doing so. Another provider implements IPushSubscriptionStore and IVapidKeyStore itself.

The subscription store is scoped and works on the caller's own context. Inside a unit of work — a command handler under the transaction decorator — a subscribe, an unsubscribe, or the cleanup of a dead subscription during a send joins that transaction and commits or rolls back with the handler's other writes; outside one (an endpoint, a job) each statement commits on its own. So the subscribe handlers below need no special pipeline, on PostgreSQL or on SQLite, where a second connection would wait for the handler's write lock.

AddElarionWebPush(...) alone registers the same services over in-memory stores — fine for tests and a single-node demo, but both the subscriptions and the key pair vanish on restart.

The VAPID key pair

Browsers bind every subscription to the server's public key, so the key pair must be stable: rotating it invalidates every subscription. IVapidKeyProvider resolves it in this order:

  1. Configuration — options.PublicKey and options.PrivateKey (base64url; set both or neither). Use this to keep the private key in a secret store.
  2. The store — the elarion_vapid_keys row.
  3. Generated on first use and stored. Two nodes racing the first start both insert with ON CONFLICT DO NOTHING and read the row back, so they agree on one pair.

With the EF stores the pair is resolved when the host starts, not in the first request that needs it. Once resolved it is cached for the life of the process, so it must be stored for good before it is used — never inside a request's transaction, whose rollback could un-store a key browsers already subscribed against. If the tables do not exist yet at start, that is logged and the pair is resolved on first use instead.

The private key is protected at rest (ADR-0078's seam): the EF store writes it through the registered ISettingValueProtector — AddElarionSettingsDataProtection() is the shipped implementation over ASP.NET Core Data Protection, whose key ring you must persist and share between nodes — bound to the pair's public key, with the protector's scheme in the row's protection column. The store fails closed: without a protector it cannot be resolved, and a payload that does not decrypt (a lost key ring) throws instead of minting a replacement pair, which would invalidate every subscription. A row written before protection existed (no protection value) is accepted once and re-protected in place, as is a payload under a retired Data Protection key. Upgrading adds the nullable protection column and widens private_key to 1024 characters — add an EF migration. Keys configured through options.PublicKey/PrivateKey never touch the store, and an application with its own IVapidKeyStore (a vault, a KMS) registers it after AddElarionWebPushEntityFrameworkCore and needs no protector. VapidKeys.Generate() mints a pair if you want to pin one from the start.

To move an existing pair into the store (so browsers' current subscriptions keep working), call IVapidKeyStore.ImportAsync(keys) once, before the first push is sent. The EF store protects the private key with its own purpose, so you never build the protection format yourself. The result is Imported, Unchanged when the identical pair is already stored (safe to run on every start), or Refused when a different pair is stored — replacing it would invalidate every subscription, so it takes an explicit overwrite: true (Replaced). Concurrent nodes importing different pairs leave exactly one winner. The provider caches the pair per process, so restart other nodes after an overwrite.

Sending

Resolve IWebPushSender (scoped) wherever the trigger lives — a handler, an integration-event consumer, a scheduled job:

[ConsumeEvent]
public sealed class NotifyOnFailedDeploy(IWebPushSender push, AppDbContext db) : IHandler<DeployFailed> {
    public async ValueTask<Result> HandleAsync(DeployFailed e, CancellationToken ct) {
        var operators = await db.Operators.Select(o => o.UserId).ToListAsync(ct);
        await push.SendToUsersAsync(operators, new WebPushMessage {
            Title = "Deploy failed",
            Body = $"{e.Service}@{e.Version} failed its health check.",
            Url = $"/deploys/{e.DeployId}",
            Tag = $"deploy-{e.DeployId}",
            Urgency = WebPushUrgency.High,
        }, ct);
        return Result.Success();
    }
}

The sender loads the users' subscriptions, encrypts the {title, body, url, tag} payload for each, and sends them with bounded concurrency (MaxConcurrentSends, default 8). The returned WebPushResult counts what happened:

Push service answerOutcomeSubscription
2xxDeliveredkept
404 / 410Removeddeleted — the browser unsubscribed or the subscription expired
malformed keys, disallowed endpointRemoveddeleted — it can never be delivered to
429, 5xx, timeout, network error, other 4xxFailedkept

A refused message (any non-2xx other than 404/410) is logged as a warning with the push service's own reason from the response body — Apple answers with {"reason":"…"}, FCM and Mozilla with a sentence — capped at 256 characters and flattened to one line. That reason is the only place a push service says why.

Web Push is best-effort: "delivered" means the push service accepted the message, not that the device showed it, and a failed send is not retried. If a notification must not be lost, send it from a durable trigger (an integration event through the outbox, a job) so the retry is yours.

Message fields map onto the protocol:

  • Tag replaces rather than stacks: the browser replaces a displayed notification with the same tag. It is deliberately not sent as the RFC 8030 Topic header — Apple's push service (web.push.apple.com) rejects every request that carries one with 400 BadWebPushTopic, whatever its value — so the push service keeps each undelivered message, and a device coming back online receives them in turn, each replacing the last. Keep TimeToLive short for messages that go stale.
  • Urgency (VeryLow … High) tells the device how urgently to wake up. High costs battery; reserve it.
  • TimeToLive is how long the push service holds the message for an offline device; it defaults to options.DefaultTimeToLive (one day). TimeSpan.Zero means "now or never".
  • The payload must fit one 4 KiB push message (about 3,990 bytes of JSON); a longer one throws. Put the details behind Url.

SendToCurrentUserAsync(message) sends to the caller's own devices — the "send a test notification" button.

To address an audience, ask the store rather than its table: IPushSubscriptionStore.ListSubscribedUserIdsAsync() returns the distinct ids of every user that owns a subscription, ListSubscribedUserIdsAsync(candidates) narrows a candidate list to those with at least one device, and ListByUsersAsync(userIds) returns the subscriptions themselves. A custom store implements all three.

Subscribing

Subscribing is an operation of your application, so it runs through your handler pipeline like every other one: global authorization rules, tenant and realm rules, audit, rate limiting and validation apply to it. The framework therefore ships the request types and the shared logic, not the handlers. Declare three [Handler]s that delegate to WebPushSubscriptionService — they appear in the JSON-RPC schema and the generated TypeScript client like any other:

[Handler("webPush.subscribe")]
public sealed class SubscribeToPush(WebPushSubscriptionService subscriptions) : IHandler<PushSubscriptionRequest> {
    public ValueTask<Result> HandleAsync(PushSubscriptionRequest request, CancellationToken ct) =>
        subscriptions.SubscribeAsync(request, cancellationToken: ct);
}

[Handler("webPush.unsubscribe")]
public sealed class UnsubscribeFromPush(WebPushSubscriptionService subscriptions) : IHandler<WebPushUnsubscribeRequest> {
    public ValueTask<Result> HandleAsync(WebPushUnsubscribeRequest request, CancellationToken ct) =>
        subscriptions.UnsubscribeAsync(request.Endpoint, ct);
}

[Handler("webPush.publicKey")]
public sealed class GetPushPublicKey(WebPushSubscriptionService subscriptions)
    : IHandler<WebPushPublicKeyRequest, Result<WebPushPublicKeyResponse>> {
    public async ValueTask<Result<WebPushPublicKeyResponse>> HandleAsync(WebPushPublicKeyRequest request, CancellationToken ct) =>
        new WebPushPublicKeyResponse(await subscriptions.GetPublicKeyAsync(ct));
}

Subscriptions belong to the current user (ICurrentUser); the service insists the caller is authenticated, so a subscription always has an owner, and add [RequirePermission] or any other attribute to the handlers to restrict who may subscribe.

MapElarionWebPush() is the HTTP binding for exactly those handlers — it resolves the registered IHandlers for PushSubscriptionRequest, WebPushUnsubscribeRequest and WebPushPublicKeyRequest and calls them, so the decorators the generator wrapped around them decide every call. It fails at startup, naming the missing handlers, if one is not registered; there is deliberately no undecorated fallback that could bypass the pipeline by accident. The wire shapes are the browser package's fixed contract:

EndpointBodyResult
GET /webpush/public-key—200 {"publicKey":"…"}
POST /webpush/subscribesubscription.toJSON()204; 400 invalid or refused; 401 anonymous; 403 forbidden
POST /webpush/unsubscribe{"endpoint":"…"}204; 401 anonymous; 403 forbidden

A handler failure maps to a bodyless status by its error kind (Validation 400, Unauthorized 401, Forbidden 403, …). A malformed body is answered 401/403 by the handler's admission gate when the caller may not call it at all, and 400 otherwise — an anonymous caller never learns the request shape. The endpoint copies the request's User-Agent header into PushSubscriptionRequest.UserAgent (a property that is not part of the JSON contract). Apply endpoint-level conventions (.RequireAuthorization(), a rate-limiter policy) to the returned group as for any minimal API.

Subscribing is an upsert by endpoint: the same device re-subscribing refreshes its keys and last-seen time, and re-subscribing under a different account reassigns the row instead of colliding on the unique endpoint. The reassignment requires the subscription's stored auth secret, which the same browser always sends. A request for an endpoint another user owns, with other keys, writes nothing and fails with the same Validation error (400) as an invalid subscription, so a user who learned someone else's endpoint cannot redirect it to keys of their own. The check is part of the store's single INSERT … ON CONFLICT … DO UPDATE … WHERE statement, not a read before the write, so concurrent subscribes for one endpoint leave exactly one owner. The secret is compared with plain SQL equality rather than in constant time. That is acceptable because it is 16 random bytes and each guess costs an authenticated request and a database round trip. A custom IPushSubscriptionStore must apply the same rule atomically and return from UpsertAsync whether it wrote the row. Unsubscribing only ever deletes the caller's own subscription.

Endpoint allow-list

The server POSTs to whatever endpoint a browser hands it, so an unrestricted endpoint would let any signed-in user aim server-side requests at an internal address. Subscriptions are therefore accepted only for https endpoints on the known push services (Google FCM, Mozilla, Apple, Windows) and their subdomains, and the delivery client does not follow redirects. Add a host to options.AllowedEndpointHosts for another push service, or set options.AllowAnyEndpointHost only if every caller that can subscribe is trusted.

The deliveries use the named HttpClient WebPushOptions.HttpClientName; configure it (services.AddHttpClient(WebPushOptions.HttpClientName)…) to add a proxy or resilience handler.

Browser

import { enablePush, fetchWebPushApi, pushAvailability, refreshOnStart } from '@swimmesberger/elarion-webpush'

const api = fetchWebPushApi()          // the MapElarionWebPush endpoints
void refreshOnStart(api)               // every app start: heals rotated or cleaned-up subscriptions

switch (pushAvailability()) {
  case 'available':                    // offer the switch
    toggle.onclick = async () => showOutcome(await enablePush(api))   // 'subscribed' | 'denied' | 'dismissed'
    break
  case 'install-first':                // iOS/iPadOS Safari outside a Home Screen app
    hint.textContent = 'Add this app to your Home Screen to turn on notifications.'
    break
  case 'unsupported':                  // hide the switch
}
  • pushAvailability() returns install-first on iOS/iPadOS 16.4+ Safari outside a Home Screen install, where PushManager does not exist yet — say "add to Home Screen first" instead of offering a switch that can never turn on. Before 16.4 it is unsupported.
  • enablePush requests permission before its first await, because Safari only honours a permission request made synchronously inside the user gesture. Call it straight from the click handler.
  • subscribe, unsubscribe, and isSubscribed are the building blocks; refreshOnStart never prompts.
  • They subscribe through the page's active service-worker registration — or the registration option, where you pass what navigator.serviceWorker.register(...) returned. Otherwise they wait for a worker to activate for at most registrationTimeoutMs (10 s) and throw a WebPushRegistrationError; navigator.serviceWorker.ready alone never settles when no worker is registered, as on a dev server that does not serve one.
  • fetchWebPushApi() targets the MapElarionWebPush() endpoints. To use the handlers over JSON-RPC instead, adapt the generated client:
const api: WebPushServerApi = {
  getPublicKey: async () => (await rpc.webPush.publicKey({})).publicKey,
  subscribe: async (subscription) => { await rpc.webPush.subscribe(subscription) },
  unsubscribe: async (endpoint) => { await rpc.webPush.unsubscribe({ endpoint }) },
}

Service worker

import { registerWebPushHandlers } from '@swimmesberger/elarion-webpush/sw'

registerWebPushHandlers(self, { icon: '/icons/192.png', badge: '/icons/badge.png' })
  • push shows the notification (a push that shows nothing is penalized by browsers, so an unreadable payload still shows its text).
  • notificationclick focuses a window already at the notification's URL, otherwise navigates an open app window there, otherwise opens one.
  • pushsubscriptionchange re-subscribes when the push service rotates the subscription and sends the new one to the server. It authenticates with same-origin cookies; a bearer-token app passes api: null and relies on refreshOnStart.

The module is ESM: bundle the worker (for example vite-plugin-pwa injectManifest) or register it with navigator.serviceWorker.register('/sw.js', { type: 'module' }).

The same worker usually also makes the app installable: Installable apps (PWA) composes registerShellRouter(self) from @swimmesberger/elarion-pwa/sw — offline shell and asset caching that never touch the API — with these handlers, and shows how to bundle the worker into one script.

Limits

  • One node sends from one database: the fan-out is in-process and bounded, which covers the 1–10 node tier. A mass-notification service (millions of devices, campaigns, analytics) is a job for a dedicated provider.
  • Delivery is at-most-once per send and there is no read receipt — Web Push has none.
  • A user who is online still gets the push; choosing between a client event and a push per call site is the application's decision for now.

On this page