# Web Push

> Send browser push notifications with flare over VAPID, only to https push service hosts on push.allowed_hosts and without redirects, and manage VAPID keys.

[flare](/docs/api/flare.md) sends browser push notifications. Push is a different channel from realtime: realtime reaches pages that hold an open connection, while a push goes to the browser vendor's push service, which wakes the browser even when no page is open. flare is written on the standard library: the RFC 8291 payload encryption and the RFC 8292 VAPID authorization are implemented in the package, with no Web Push library.

## Subscriptions belong to the application

When a browser subscribes, the frontend posts its `PushSubscription` (the endpoint URL and the `p256dh` and `auth` keys) to an application route, and the application stores it in its own table. flare never reads the database. Code that sends a push passes a `flare.Subscription` to the `flare.Pusher` that `flare.From` returns through `flare.Service.Pusher`.

For the operator commands below, the application also publishes a `flare.SubscriptionSource` on the app, which reads a user's stored subscriptions.

## Sending

`flare.Pusher.Send` encrypts the payload for the subscriber and posts it to the endpoint with the VAPID `Authorization` header. `flare.SendOptions` sets the `TTL` (default `push.ttl`), `Urgency` and `Topic` headers:

```go
// A stand-in push service: 201 for a live subscription, 410 for one the
// browser dropped.
push := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	if r.URL.Path == "/gone" {
		w.WriteHeader(http.StatusGone)
		return
	}
	fmt.Println("push service got", r.Header.Get("Content-Encoding"), r.Header.Get("TTL"), r.Header.Get("Urgency"))
	w.WriteHeader(http.StatusCreated)
}))
defer push.Close()

keys, err := flare.GenerateVAPIDKeys() // websockets:generate-vapid-keys
if err != nil {
	fmt.Println(err)
	return
}
cfg := flare.Config{
	Enabled:      true,
	PublicKey:    keys.PublicKey,
	PrivateKey:   keys.PrivateKey,
	Subject:      "mailto:admin@example.com",
	TTL:          time.Hour,
	AllowedHosts: []string{"127.0.0.1"}, // production keeps the default push services
}
// The test server's client trusts its certificate.
pusher := flare.NewVAPIDPusher(cfg, push.Client())

ctx := context.Background()
payload := []byte(`{"title":"New comment","body":"Someone replied to your post"}`)
for _, endpoint := range []string{
	push.URL + "/live",
	push.URL + "/gone",
	"https://push.attacker.example/steal",
	"http://127.0.0.1/plain",
} {
	sub, err := browserSubscription(endpoint)
	if err != nil {
		fmt.Println(err)
		return
	}
	err = pusher.Send(ctx, sub, payload, flare.SendOptions{Urgency: "normal"})
	switch {
	case err == nil:
		fmt.Println("sent")
	case errors.Is(err, flare.ErrSubscriptionGone):
		fmt.Println("gone: delete the subscription")
	case errors.Is(err, flare.ErrEndpointNotAllowed):
		fmt.Println("refused before connecting")
	default:
		fmt.Println("error:", err)
	}
}
// Formatting the keys never prints the private key.
fmt.Println(strings.Contains(fmt.Sprintf("%v %#v", keys, keys), keys.PrivateKey))
// Output:
// push service got aes128gcm 3600 normal
// sent
// gone: delete the subscription
// refused before connecting
// refused before connecting
// false
```

- A 2xx answer is success.
- 404 and 410 return `flare.ErrSubscriptionGone`: the browser unsubscribed, so delete the stored subscription.
- Any other status returns a `flare.StatusError` with the code, never the response body.
- A payload over `flare.MaxPayloadSize` (3993 bytes) returns `flare.ErrPayloadTooLarge`.
- While `push.enabled` is false, nothing is sent and `flare.ErrPushDisabled` is returned.

A send is one HTTP request with a 10-second timeout. Send from a queued job when a request would otherwise wait for it; see [Queued jobs](/docs/services/jobs.md).

## Endpoint safety

Endpoints come from browsers, so they are untrusted URLs. flare sends only to `https` endpoints whose host is on `push.allowed_hosts`, checks this before it opens a connection, and never follows a redirect, so a push service cannot bounce the request to another host. A refused endpoint returns `flare.ErrEndpointNotAllowed`, which names the host but never the endpoint path.

The default allowlist, `flare.DefaultAllowedHosts`, covers Firebase Cloud Messaging, Mozilla autopush, Apple and Windows push. `*.example.com` matches any subdomain but not `example.com` itself:

```go
allowed := []string{"fcm.googleapis.com", "*.push.apple.com"}
for _, host := range []string{"fcm.googleapis.com", "api.push.apple.com", "push.apple.com", "evil.example"} {
	fmt.Println(host, flare.HostAllowed(host, allowed))
}
// Output:
// fcm.googleapis.com true
// api.push.apple.com true
// push.apple.com false
// evil.example false
```

Keep the default unless you know a browser your users run pushes through another service.

## VAPID keys

A push service accepts a push only when it carries a token signed with the application's VAPID key pair. Generate the pair once:

```sh
./bin/acme websockets:generate-vapid-keys
```

It prints `SUMMER_PUSH__PUBLIC_KEY=...` and `SUMMER_PUSH__PRIVATE_KEY=...` lines to set in the environment. With `--update` it saves the keys to the environment's `overrides.yaml` instead. Keep the private key out of committed files. flare never writes the private key to a log or an error, and `flare.VAPIDKeys` and `flare.Config` redact it when printed.

The frontend needs the public key to subscribe; serve it from a route of your own. Set `push.subject` to a `mailto:` or `https:` contact address for the push services, and `push.enabled` to `true`:

```yaml
enabled: true
subject: mailto:admin@example.com
```

in `config/push.yaml`.

`websockets:test-push <user_id>` prints the push configuration without the key values, lists the user's subscriptions from the published `flare.SubscriptionSource`, and sends each one a test notification:

```sh
./bin/acme websockets:test-push 42
```
