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'sp256dhkey, mixed with itsauthsecret through HKDF-SHA-256 (crypto/hkdf), then one AES-128-GCM record in theaes128gcmcontent coding. The implementation reproduces the RFC 8291 Appendix A test vector byte for byte. - Every request carries
Authorization: vapid t=<JWT>, k=<public key>fromflare.VAPIDHeader. The ES256 token'saudis the endpoint's origin,expliesflare.VAPIDTokenLifetime(12 hours) ahead andsubispush.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.Pusherwith one method,Send(ctx, sub, payload, opts).flare.SendOptionssets theTTL(defaultpush.ttl),UrgencyandTopicheaders.- The VAPID driver POSTs the encrypted body with
TTL,Content-Encoding: aes128gcm,Content-Type: application/octet-stream, the optionalUrgencyandTopicand the VAPIDAuthorizationheader. A 2xx answer is success. 404 and 410 returnflare.ErrSubscriptionGone, so the caller can delete the subscription. Any other status returns a*flare.StatusErrorwith the code and without the response body. Requests time out afterflare.DefaultTimeout(10 s). - Nothing is sent while
push.enabledis false:Sendreturnsflare.ErrPushDisabled. - Endpoint allowlist:
flare.HostAllowedmatches a host againstpush.allowed_hosts, where*.example.commatches any subdomain ofexample.com(notexample.comitself). The defaults,flare.DefaultAllowedHosts, are Firebase Cloud Messaging, Mozilla autopush, Apple and Windows push. A refused endpoint returnsflare.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 returnsflare.ErrPayloadTooLarge. - VAPID keys:
flare.GenerateVAPIDKeysreturns a P-256 pair as unpadded base64url (flare.PublicKeyLength, 87 characters, andflare.PrivateKeyLength, 43 characters).flare.ParseVAPIDKeysaccepts padded or unpadded input and checks that the public key belongs to the private key; a bad pair returnsflare.ErrInvalidVAPIDKeys. A subject that is notmailto:orhttps:returnsflare.ErrInvalidSubject. - The private key never reaches logs or formatted output:
flare.VAPIDKeysandflare.Configredact it inString,GoStringand (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,bonfireandcompassfrom 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/cipherandnet/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.