API reference

flare

Web Push delivery with VAPID (RFC 8292) and aes128gcm payload encryption (RFC 8291) behind a small Pusher interface.

On this page

import "git.golem15.com/golem15/summercms/modules/flare"

Overview#

flare sends browser push notifications. Push is a separate channel from realtime: lighthouse publishes to clients that hold an open connection, while flare hands a message to the browser vendor's push service, which wakes the browser even when no page is open.

The application owns the subscriptions. When a browser subscribes, the frontend posts its PushSubscription (the endpoint URL and the p256dh and auth keys) to the application, which stores it. flare never reads a database. Code that sends a push passes a flare.Subscription to a flare.Pusher, and operator tooling reads stored subscriptions through a flare.SubscriptionSource that the application publishes on the app.

flare.From builds the app-scoped flare.Service from push.* on first use. Its flare.Service.Pusher is the VAPID driver, flare.VAPIDPusher, which talks to push services directly with the standard library:

  • The payload is encrypted for the subscriber with flare.Encrypt: an ephemeral P-256 key agreement (crypto/ecdh) with the subscription's p256dh key, mixed with its auth secret through HKDF-SHA-256 (crypto/hkdf), then one AES-128-GCM record in the aes128gcm content coding. The implementation reproduces the RFC 8291 Appendix A test vector byte for byte.
  • Every request carries Authorization: vapid t=<JWT>, k=<public key> from flare.VAPIDHeader. The ES256 token's aud is the endpoint's origin, exp lies flare.VAPIDTokenLifetime (12 hours) ahead and sub is push.subject.

Endpoints come from browsers, so they are untrusted URLs. The driver only sends to https endpoints whose host is in push.allowed_hosts, checks this before it opens a connection, and never follows a redirect.

Features#

  • flare.Pusher with one method, Send(ctx, sub, payload, opts). flare.SendOptions sets the TTL (default push.ttl), Urgency and Topic headers.
  • The VAPID driver POSTs the encrypted body with TTL, Content-Encoding: aes128gcm, Content-Type: application/octet-stream, the optional Urgency and Topic and the VAPID Authorization header. A 2xx answer is success. 404 and 410 return flare.ErrSubscriptionGone, so the caller can delete the subscription. Any other status returns a *flare.StatusError with the code and without the response body. Requests time out after flare.DefaultTimeout (10 s).
  • Nothing is sent while push.enabled is false: Send returns flare.ErrPushDisabled.
  • Endpoint allowlist: flare.HostAllowed matches a host against push.allowed_hosts, where *.example.com matches any subdomain of example.com (not example.com itself). The defaults, flare.DefaultAllowedHosts, are Firebase Cloud Messaging, Mozilla autopush, Apple and Windows push. A refused endpoint returns flare.ErrEndpointNotAllowed, and the error names the host, never the endpoint path.
  • Payloads up to flare.MaxPayloadSize (3993 bytes, the RFC 8291 limit for a 4096-byte body). A larger one returns flare.ErrPayloadTooLarge.
  • VAPID keys: flare.GenerateVAPIDKeys returns a P-256 pair as unpadded base64url (flare.PublicKeyLength, 87 characters, and flare.PrivateKeyLength, 43 characters). flare.ParseVAPIDKeys accepts padded or unpadded input and checks that the public key belongs to the private key; a bad pair returns flare.ErrInvalidVAPIDKeys. A subject that is not mailto: or https: returns flare.ErrInvalidSubject.
  • The private key never reaches logs or formatted output: flare.VAPIDKeys and flare.Config redact it in String, GoString and (for the config) LogValue, and no error carries key material.

Usage#

Send a push to one stored subscription:

svc, err := flare.From(app)
if err != nil {
	return err
}
payload := []byte(`{"title":"New comment","body":"Someone replied to your post"}`)
err = svc.Pusher().Send(ctx, flare.Subscription{
	Endpoint: row.Endpoint,
	P256dh:   row.P256dh,
	Auth:     row.Auth,
}, payload, flare.SendOptions{Urgency: "normal"})
if errors.Is(err, flare.ErrSubscriptionGone) {
	// The browser unsubscribed: delete the row.
}

Publish a subscription source from a plugin's Boot, so operator commands can read the stored subscriptions of a user:

type blogSubscriptions struct{ db *gorm.DB }

func (s blogSubscriptions) Subscriptions(ctx context.Context, userID uint) ([]flare.SubscriptionInfo, error) {
	var user models.User
	if err := s.db.WithContext(ctx).First(&user, userID).Error; err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, flare.ErrUserNotFound
		}
		return nil, err
	}
	var rows []models.PushSubscription
	if err := s.db.WithContext(ctx).Where("user_id = ?", userID).Find(&rows).Error; err != nil {
		return nil, err
	}
	out := make([]flare.SubscriptionInfo, 0, len(rows))
	for _, r := range rows {
		out = append(out, flare.SubscriptionInfo{
			Subscription: flare.Subscription{Endpoint: r.Endpoint, P256dh: r.P256dh, Auth: r.Auth},
			ID:           r.ID,
			UserAgent:    r.UserAgent,
			SubscribedAt: &r.CreatedAt,
		})
	}
	return out, nil
}

// In Boot:
if err := app.Publish[flare.SubscriptionSource](blogSubscriptions{db: gdb}); err != nil {
	return err
}

API reference#

Identifier Description
flare.From(app) The app's *flare.Service, built from push.* and published on first use.
flare.Service Config, Enabled, Pusher, SetHTTPClient (replace the driver's HTTP client, for example in tests) and Logger.
flare.Pusher Send(ctx, sub, payload, opts) error.
flare.VAPIDPusher, flare.NewVAPIDPusher(cfg, hc) The VAPID driver. hc may be nil; a given client is copied and never follows redirects.
flare.Subscription Endpoint, P256dh, Auth, as PushSubscription.toJSON returns them.
flare.SendOptions TTL, Urgency, Topic.
flare.SubscriptionSource, flare.SubscriptionInfo The application's subscription store: Subscriptions(ctx, userID) returns the subscriptions with ID, UserAgent, SubscribedAt and LastUsedAt.
flare.Config, flare.LoadConfig The push.* settings with their defaults; Keys returns the key pair.
flare.VAPIDKeys, flare.GenerateVAPIDKeys, flare.ParseVAPIDKeys VAPID key pairs as unpadded base64url.
flare.VAPIDHeader(endpoint, subject, keys, now) The RFC 8292 Authorization header value.
flare.Encrypt(payload, sub) The RFC 8291 aes128gcm request body.
flare.HostAllowed(host, allowed), flare.DefaultAllowedHosts The endpoint host allowlist and its default.
flare.Commands(app), flare.GenerateVAPIDKeysCommandName, flare.TestPushCommandName The websockets:generate-vapid-keys and websockets:test-push commands and their names.
flare.ErrPushDisabled, flare.ErrEndpointNotAllowed, flare.ErrSubscriptionGone, flare.ErrUserNotFound, flare.ErrPayloadTooLarge, flare.ErrInvalidVAPIDKeys, flare.ErrInvalidSubject, flare.StatusError Errors.
flare.ContentEncoding, flare.MaxPayloadSize, flare.DefaultTTL, flare.DefaultTimeout, flare.VAPIDTokenLifetime, flare.PublicKeyLength, flare.PrivateKeyLength Constants.

Configuration#

Key Default Description
push.enabled false Nothing is sent while false.
push.public_key "" VAPID public key, base64url (87 characters unpadded).
push.private_key "" VAPID private key, base64url (43 characters). Keep it out of committed files; set SUMMER_PUSH__PRIVATE_KEY.
push.subject "" VAPID sub claim: a mailto: or https: contact URI.
push.ttl 2419200 Default TTL header, in seconds or as a duration string.
push.allowed_hosts FCM, Mozilla autopush, *.push.apple.com, *.notify.windows.com Push service hosts an endpoint may point at, as a list or a comma-separated string.

CLI commands#

flare.Commands(app) returns two commands for the application binary. An application adds them to the list its plugin returns from Commands.

Command Description
websockets:generate-vapid-keys [--update] [--show-current] Shows the configured keys, truncated to the first 8 and last 4 characters with their length and a check mark when they decode to a valid pair. --show-current stops there. Otherwise it generates a new P-256 pair, validates its length and base64url alphabet and prints both keys. With --update the keys are saved through compass.Config.Set and compass.Config.Persist to env/<environment>/overrides.yaml in the config directory (mode 0600; other keys in the file are kept). Without it, the command prints SUMMER_PUSH__PUBLIC_KEY=… and SUMMER_PUSH__PRIVATE_KEY=… lines to set by hand.
websockets:test-push <user_id> [--show-config] Prints the push configuration: enabled, whether each key is set with its length (never the value), the subject and its format. It then reads the user's subscriptions from the published flare.SubscriptionSource, lists them (endpoint shortened to 60 characters, user agent, when subscribed and last used) and asks Send test notification? (default yes; a non-interactive run takes the default). It sends one encrypted test push to each subscription and reports each result. --show-config is accepted for compatibility; the configuration is always shown.

websockets:test-push exits 1 when no subscription source is published (no subscription source registered), when the user is unknown or has no subscriptions, when push is disabled (it lists the subscriptions but sends nothing), and when any send fails. The test payload is {"title":"<app.name> test","body":"This is a test push notification sent at HH:MM:SS","data":{"test":true,"timestamp":<unix>}}.

Dependencies#

  • backpack, bonfire and compass from this repository.
  • github.com/golang-jwt/jwt/v5 (the ES256 VAPID token).
  • Everything else is the standard library: crypto/ecdh, crypto/ecdsa, crypto/hkdf, crypto/aes, crypto/cipher and net/http. No Web Push library is used.

Testing#

go test ./modules/flare/...

TestRFC8291AppendixA fixes the RFC's salt and application server key and compares the output with the RFC's published bytes. The send tests run an httptest.NewTLSServer push service with 127.0.0.1 in the allowlist; it verifies the VAPID token with the key from k= and decrypts the body as a browser would. Pass the test server's client to flare.NewVAPIDPusher or flare.Service.SetHTTPClient so it trusts the test certificate.