Files
summercms/modules/flare
Jakub Zych a9af0d77c7 feat(11-04): add flare Web Push with a stdlib VAPID driver
- RFC 8291 aes128gcm encryption from crypto/ecdh, crypto/hkdf and AES-GCM,
  matching the RFC 8291 Appendix A vector byte for byte
- RFC 8292 vapid t=<ES256 JWT>, k=<key> header (aud origin, exp +12h, sub)
- Pusher, Subscription, SendOptions, SubscriptionSource, Service and From
  reading push.* (enabled, keys, subject, ttl, allowed_hosts)
- sends only to https endpoints on push.allowed_hosts, checked before
  dialing, and never follows redirects; 404/410 map to ErrSubscriptionGone
- module README and root modules row
2026-09-30 13:43:09 +02:00
..

flare

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

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.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.

Dependencies

  • backpack 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.