Web Push sender (Feature 19) (#164)
CI / ci (push) Successful in 5m31s

Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #164.
This commit is contained in:
2026-10-09 13:45:21 -04:00
committed by approver_agent
parent 104396da55
commit d5ab945ee7
8 changed files with 910 additions and 0 deletions
+7
View File
@@ -149,6 +149,13 @@ func run(cfg config.Config, logger *slog.Logger) error {
if err != nil {
return err
}
pushSender, err := push.NewSender(db, logger, cfg.VAPID.PublicKey, cfg.VAPID.PrivateKey.Reveal(), cfg.VAPID.Subject)
if err != nil {
return err
}
if err := notifier.Register(notify.ChannelPush, pushSender); err != nil {
return err
}
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
if err != nil {
+3
View File
@@ -3,6 +3,7 @@ module code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest
go 1.25.0
require (
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/coreos/go-oidc/v3 v3.21.0
github.com/go-chi/chi/v5 v5.3.1
github.com/oklog/ulid/v2 v2.1.2
@@ -14,10 +15,12 @@ require (
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.75.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
+69
View File
@@ -1,3 +1,5 @@
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -6,6 +8,9 @@ github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -21,18 +26,82 @@ github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
+83
View File
@@ -23,6 +23,89 @@ func (q *Queries) DeletePushSubscription(ctx context.Context, endpoint string) (
return result.RowsAffected()
}
const deletePushSubscriptionByID = `-- name: DeletePushSubscriptionByID :exec
DELETE FROM push_subscriptions
WHERE id = ?
`
// Removes a subscription the push service reports gone (404 or 410).
func (q *Queries) DeletePushSubscriptionByID(ctx context.Context, id string) error {
_, err := q.db.ExecContext(ctx, deletePushSubscriptionByID, id)
return err
}
const listPushSubscriptions = `-- name: ListPushSubscriptions :many
SELECT id, endpoint, p256dh, auth
FROM push_subscriptions
ORDER BY created_at, id
`
type ListPushSubscriptionsRow struct {
ID string
Endpoint string
P256dh string
Auth string
}
// Every stored subscription with its keys, for the push sender, oldest
// first.
func (q *Queries) ListPushSubscriptions(ctx context.Context) ([]ListPushSubscriptionsRow, error) {
rows, err := q.db.QueryContext(ctx, listPushSubscriptions)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListPushSubscriptionsRow
for rows.Next() {
var i ListPushSubscriptionsRow
if err := rows.Scan(
&i.ID,
&i.Endpoint,
&i.P256dh,
&i.Auth,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const recordPushFailure = `-- name: RecordPushFailure :exec
UPDATE push_subscriptions
SET failure_count = failure_count + 1
WHERE id = ?
`
// Counts a delivery to a subscription that the push service did not accept.
func (q *Queries) RecordPushFailure(ctx context.Context, id string) error {
_, err := q.db.ExecContext(ctx, recordPushFailure, id)
return err
}
const recordPushSuccess = `-- name: RecordPushSuccess :exec
UPDATE push_subscriptions
SET last_success_at = ?
WHERE id = ?
`
type RecordPushSuccessParams struct {
LastSuccessAt sql.NullString
ID string
}
// Records that the push service accepted a notification for a subscription.
func (q *Queries) RecordPushSuccess(ctx context.Context, arg RecordPushSuccessParams) error {
_, err := q.db.ExecContext(ctx, recordPushSuccess, arg.LastSuccessAt, arg.ID)
return err
}
const upsertPushSubscription = `-- name: UpsertPushSubscription :one
INSERT INTO push_subscriptions (
id, endpoint, p256dh, auth, user_agent, failure_count, created_at, updated_at
+9
View File
@@ -68,6 +68,8 @@ type Querier interface {
DeleteAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
DeleteIntegrationsGoogle(ctx context.Context) error
DeletePushSubscription(ctx context.Context, endpoint string) (int64, error)
// Removes a subscription the push service reports gone (404 or 410).
DeletePushSubscriptionByID(ctx context.Context, id string) error
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
DeleteRecipeSteps(ctx context.Context, recipeID string) error
DeleteShoppingGroup(ctx context.Context, id string) error
@@ -188,6 +190,9 @@ type Querier interface {
// The planned dinners dated from from_date up to but not including
// to_date, oldest first. Days with no dinner are left out.
ListPlannedDinners(ctx context.Context, arg ListPlannedDinnersParams) ([]ListPlannedDinnersRow, error)
// Every stored subscription with its keys, for the push sender, oldest
// first.
ListPushSubscriptions(ctx context.Context) ([]ListPushSubscriptionsRow, error)
// Reads for the AI household context block (internal/ai/harness).
// The most recent names the household turned down, newest first, as
// ListPassedNames has them.
@@ -255,6 +260,10 @@ type Querier interface {
// event id while its dinner stays the same; a new dinner clears it, since
// Calendar no longer shows the day as planned.
PutPlanDay(ctx context.Context, arg PutPlanDayParams) error
// Counts a delivery to a subscription that the push service did not accept.
RecordPushFailure(ctx context.Context, id string) error
// Records that the push service accepted a notification for a subscription.
RecordPushSuccess(ctx context.Context, arg RecordPushSuccessParams) error
// Records a new draft over plan id: its status and note, raising its
// revision.
RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) error
+195
View File
@@ -0,0 +1,195 @@
package push
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
webpush "github.com/SherClockHolmes/webpush-go"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
)
// pushTTL is how long a push service keeps a notification for a device
// that is offline.
const pushTTL = 12 * time.Hour
// maxDrainBytes bounds how much of a push service response body is read.
const maxDrainBytes = 4096
// notice is what the service worker shows (web/src/push/notification.ts).
type notice struct {
Title string `json:"title"`
Body string `json:"body"`
URL string `json:"url"`
Tag string `json:"tag"`
}
// Sender is the notify.Sender of the push channel: it sends a notification
// to every stored subscription, signed with the VAPID key pair.
//
// Per subscription, a notification the push service accepts records
// last_success_at; one it refuses or cannot be sent increments
// failure_count, and a 404 or 410 deletes the subscription, which the push
// service no longer has. Send succeeds when at least one subscription
// accepted the notification, and when there are no subscriptions at all.
//
// Errors and logs name a subscription by its id, never by its endpoint,
// which is a capability URL.
type Sender struct {
st *store.Store
logger *slog.Logger
// subscriber is the VAPID subject as webpush-go takes it: an https://
// URL, or the bare email address of a mailto: subject.
subscriber string
publicKey string
privateKey string
// client sends the push requests; tests replace it.
client webpush.HTTPClient
// now is the clock last_success_at is recorded on; tests replace it.
now func() time.Time
}
// NewSender returns the push channel's sender over st, signing with the
// VAPID key pair publicKey and privateKey (vapid's encoding) and naming
// subject, a mailto: URI or https:// URL, as the contact.
func NewSender(st *store.Store, logger *slog.Logger, publicKey, privateKey, subject string) (*Sender, error) {
if st == nil {
return nil, errors.New("push: nil store")
}
if logger == nil {
return nil, errors.New("push: nil logger")
}
pub, err := vapid.ParsePublicKey(publicKey)
if err != nil {
return nil, fmt.Errorf("push: VAPID public key: %w", err)
}
priv, err := vapid.ParsePrivateKey(privateKey)
if err != nil {
return nil, fmt.Errorf("push: VAPID private key: %w", err)
}
if err := vapid.CheckPair(pub, priv); err != nil {
return nil, fmt.Errorf("push: VAPID keys: %w", err)
}
// webpush-go prefixes mailto: to any subject not starting with https:,
// so a mailto: subject is handed over as its bare address.
var subscriber string
if addr, ok := strings.CutPrefix(subject, "mailto:"); ok && addr != "" {
subscriber = addr
} else if strings.HasPrefix(subject, "https://") {
subscriber = subject
} else {
return nil, fmt.Errorf("push: VAPID subject %q is not a mailto: URI or https:// URL", subject)
}
return &Sender{
st: st,
logger: logger,
subscriber: subscriber,
publicKey: publicKey,
privateKey: privateKey,
client: &http.Client{},
now: time.Now,
}, nil
}
// Send sends m to every stored subscription. It returns nil when at least
// one accepted it or none is stored, and otherwise an error listing why
// each failed, so the outbox retries. A database error is returned at once.
func (s *Sender) Send(ctx context.Context, m notify.Message) error {
logger := s.logger.With("notification_id", m.ID, "event_key", m.EventKey)
subs, err := db.New(s.st.Reader).ListPushSubscriptions(ctx)
if err != nil {
return fmt.Errorf("push: list subscriptions: %w", err)
}
if len(subs) == 0 {
logger.InfoContext(ctx, "push notification has no subscriptions to send to; nothing sent")
return nil
}
message, err := json.Marshal(notice{Title: m.Payload.Title, Body: m.Payload.Body, URL: m.Payload.URL, Tag: m.Payload.Tag})
if err != nil {
return fmt.Errorf("push: encode notification: %w", err)
}
q := db.New(s.st.Writer)
delivered := 0
var failures []string
for _, sub := range subs {
status, err := s.sendOne(ctx, message, sub)
switch {
case err == nil && status >= 200 && status < 300:
delivered++
at := sql.NullString{String: timestamp(s.now()), Valid: true}
if err := q.RecordPushSuccess(ctx, db.RecordPushSuccessParams{LastSuccessAt: at, ID: sub.ID}); err != nil {
return fmt.Errorf("push: record success of subscription %s: %w", sub.ID, err)
}
continue
case err == nil && (status == http.StatusNotFound || status == http.StatusGone):
failures = append(failures, fmt.Sprintf("subscription %s gone (status %d), removed", sub.ID, status))
logger.InfoContext(ctx, "push subscription gone; removed", "subscription_id", sub.ID, "status", status)
if err := q.DeletePushSubscriptionByID(ctx, sub.ID); err != nil {
return fmt.Errorf("push: remove gone subscription %s: %w", sub.ID, err)
}
continue
case err == nil:
failures = append(failures, fmt.Sprintf("subscription %s: status %d", sub.ID, status))
default:
failures = append(failures, fmt.Sprintf("subscription %s: %v", sub.ID, err))
}
if err := q.RecordPushFailure(ctx, sub.ID); err != nil {
return fmt.Errorf("push: record failure of subscription %s: %w", sub.ID, err)
}
}
if delivered == 0 {
return fmt.Errorf("push: no subscription accepted the notification: %s", strings.Join(failures, "; "))
}
if len(failures) > 0 {
logger.WarnContext(ctx, "push notification not accepted by every subscription",
"delivered", delivered, "failures", strings.Join(failures, "; "))
}
return nil
}
// sendOne sends message to sub and returns the push service's status. Its
// error never quotes the endpoint.
func (s *Sender) sendOne(ctx context.Context, message []byte, sub db.ListPushSubscriptionsRow) (int, error) {
resp, err := webpush.SendNotificationWithContext(ctx, message, &webpush.Subscription{
Endpoint: sub.Endpoint,
Keys: webpush.Keys{Auth: sub.Auth, P256dh: sub.P256dh},
}, &webpush.Options{
HTTPClient: s.client,
Subscriber: s.subscriber,
TTL: int(pushTTL / time.Second),
VAPIDPublicKey: s.publicKey,
VAPIDPrivateKey: s.privateKey,
})
if err != nil {
// A *url.Error quotes the request URL, the endpoint; keep only
// its cause.
var ue *url.Error
if errors.As(err, &ue) {
return 0, fmt.Errorf("request failed: %w", ue.Err)
}
return 0, err
}
defer resp.Body.Close()
// The status is the outcome; the body is read only so the connection
// can be reused.
io.CopyN(io.Discard, resp.Body, maxDrainBytes)
return resp.StatusCode, nil
}
// timestamp returns t in the stored UTC RFC 3339 form.
func timestamp(t time.Time) string {
return store.Timestamp{Time: t.UTC().Truncate(time.Millisecond)}.String()
}
+520
View File
@@ -0,0 +1,520 @@
package push
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/ecdh"
"crypto/hkdf"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/binary"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
)
const subject = "mailto:kitchen@example.com"
// sentAt is the sender's fake clock.
var sentAt = time.Date(2026, 10, 9, 17, 0, 0, 0, time.UTC)
// device is a browser's push subscription with the private half of its
// keys, so a test can read what was sent to it.
type device struct {
path string
key *ecdh.PrivateKey
auth []byte
}
func newDevice(t *testing.T, path string) device {
t.Helper()
k, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
auth := make([]byte, 16)
rand.Read(auth)
return device{path: path, key: k, auth: auth}
}
// request is one request the push service received.
type request struct {
path string
header http.Header
body []byte
}
// pushService is an httptest push service answering each path with its
// status.
type pushService struct {
srv *httptest.Server
status map[string]int
mu sync.Mutex
got []request
}
func newPushService(t *testing.T, status map[string]int) *pushService {
t.Helper()
ps := &pushService{status: status}
ps.srv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Error(err)
}
ps.mu.Lock()
ps.got = append(ps.got, request{path: r.URL.Path, header: r.Header.Clone(), body: body})
ps.mu.Unlock()
code, ok := ps.status[r.URL.Path]
if !ok {
t.Errorf("unexpected push to %s", r.URL.Path)
code = http.StatusBadRequest
}
w.WriteHeader(code)
}))
t.Cleanup(ps.srv.Close)
return ps
}
func (ps *pushService) requests() []request {
ps.mu.Lock()
defer ps.mu.Unlock()
return append([]request(nil), ps.got...)
}
// newTestSender returns a sender over a migrated store with the devices
// subscribed to ps, and the VAPID key pair it signs with.
func newTestSender(t *testing.T, ps *pushService, devices ...device) (*Sender, *store.Store, vapid.KeyPair) {
t.Helper()
svc, st := newTestService(t)
for _, d := range devices {
_, err := svc.Subscribe(context.Background(), SubscribeRequest{
Endpoint: ps.srv.URL + d.path,
P256dh: keyEncoding.EncodeToString(d.key.PublicKey().Bytes()),
Auth: keyEncoding.EncodeToString(d.auth),
})
if err != nil {
t.Fatal(err)
}
}
pair, err := vapid.Generate()
if err != nil {
t.Fatal(err)
}
s, err := NewSender(st, slog.New(slog.NewTextHandler(io.Discard, nil)), pair.PublicKey, pair.PrivateKey, subject)
if err != nil {
t.Fatal(err)
}
s.client = ps.srv.Client()
s.now = func() time.Time { return sentAt }
return s, st, pair
}
func message() notify.Message {
return notify.Message{
ID: "01NOTIFICATION",
EventKey: "plan_ready:2026-10-12",
Channel: notify.ChannelPush,
Payload: notify.Payload{
Event: notify.PlanReady,
Title: "This week's plan is ready",
Body: "Five dinners on the Draft board.",
URL: "/plan",
Tag: "plan_ready:2026-10-12",
Week: "2026-10-12",
},
Attempt: 1,
}
}
// pushState is a subscription's delivery record.
type pushState struct {
failures int
lastSuccess sql.NullString
}
// states returns the delivery record of each stored subscription by path.
func states(t *testing.T, st *store.Store, ps *pushService) map[string]pushState {
t.Helper()
rs, err := st.Reader.Query(`SELECT endpoint, failure_count, last_success_at FROM push_subscriptions`)
if err != nil {
t.Fatal(err)
}
defer rs.Close()
got := map[string]pushState{}
for rs.Next() {
var ep string
var s pushState
if err := rs.Scan(&ep, &s.failures, &s.lastSuccess); err != nil {
t.Fatal(err)
}
got[strings.TrimPrefix(ep, ps.srv.URL)] = s
}
if err := rs.Err(); err != nil {
t.Fatal(err)
}
return got
}
// decrypt reads an aes128gcm push message body (RFC 8291) as d.
func decrypt(t *testing.T, d device, body []byte) []byte {
t.Helper()
if len(body) < 21 {
t.Fatalf("body of %d bytes has no header", len(body))
}
salt, rs, idLen := body[:16], binary.BigEndian.Uint32(body[16:20]), int(body[20])
if rs != 4096 || idLen != 65 {
t.Fatalf("record size %d, key id length %d", rs, idLen)
}
asPublic, ciphertext := body[21:21+idLen], body[21+idLen:]
peer, err := ecdh.P256().NewPublicKey(asPublic)
if err != nil {
t.Fatal(err)
}
secret, err := d.key.ECDH(peer)
if err != nil {
t.Fatal(err)
}
info := append(append([]byte("WebPush: info\x00"), d.key.PublicKey().Bytes()...), asPublic...)
ikm, err := hkdf.Key(sha256.New, secret, d.auth, string(info), 32)
if err != nil {
t.Fatal(err)
}
cek, err := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: aes128gcm\x00", 16)
if err != nil {
t.Fatal(err)
}
nonce, err := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: nonce\x00", 12)
if err != nil {
t.Fatal(err)
}
block, err := aes.NewCipher(cek)
if err != nil {
t.Fatal(err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
t.Fatal(err)
}
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
t.Fatalf("decrypt: %v", err)
}
plain = bytes.TrimRight(plain, "\x00")
if len(plain) == 0 || plain[len(plain)-1] != 0x02 {
t.Fatal("no last-record delimiter")
}
return plain[:len(plain)-1]
}
// vapidClaims checks the VAPID Authorization header names pair's public key
// and returns its token's claims (the signature is not checked).
func vapidClaims(t *testing.T, header string, pair vapid.KeyPair) map[string]any {
t.Helper()
token, key, ok := strings.Cut(strings.TrimPrefix(header, "vapid t="), ", k=")
if !ok || !strings.HasPrefix(header, "vapid t=") {
t.Fatalf("Authorization = %q, want vapid t=…, k=…", header)
}
if key != pair.PublicKey {
t.Errorf("k = %q, want the VAPID public key %q", key, pair.PublicKey)
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
t.Fatalf("token has %d parts", len(parts))
}
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
t.Fatal(err)
}
var claims map[string]any
if err := json.Unmarshal(raw, &claims); err != nil {
t.Fatal(err)
}
return claims
}
func TestSendDeliversToEveryDevice(t *testing.T) {
phone, tablet := newDevice(t, "/push/phone"), newDevice(t, "/push/tablet")
ps := newPushService(t, map[string]int{phone.path: http.StatusCreated, tablet.path: http.StatusCreated})
s, st, pair := newTestSender(t, ps, phone, tablet)
if err := s.Send(context.Background(), message()); err != nil {
t.Fatalf("Send: %v", err)
}
reqs := ps.requests()
if len(reqs) != 2 {
t.Fatalf("push service got %d requests, want 2", len(reqs))
}
for _, r := range reqs {
d := phone
if r.path == tablet.path {
d = tablet
}
if got := r.header.Get("TTL"); got != "43200" {
t.Errorf("%s: TTL = %q, want 43200 (12 hours)", r.path, got)
}
if got := r.header.Get("Content-Encoding"); got != "aes128gcm" {
t.Errorf("%s: Content-Encoding = %q", r.path, got)
}
claims := vapidClaims(t, r.header.Get("Authorization"), pair)
if claims["sub"] != subject {
t.Errorf("%s: VAPID sub = %v, want %s", r.path, claims["sub"], subject)
}
if claims["aud"] != ps.srv.URL {
t.Errorf("%s: VAPID aud = %v, want %s", r.path, claims["aud"], ps.srv.URL)
}
var got map[string]string
if err := json.Unmarshal(decrypt(t, d, r.body), &got); err != nil {
t.Fatal(err)
}
want := map[string]string{
"title": "This week's plan is ready",
"body": "Five dinners on the Draft board.",
"url": "/plan",
"tag": "plan_ready:2026-10-12",
}
if len(got) != len(want) {
t.Errorf("%s: payload = %v, want %v", r.path, got, want)
}
for k, v := range want {
if got[k] != v {
t.Errorf("%s: payload %s = %q, want %q", r.path, k, got[k], v)
}
}
}
want := pushState{lastSuccess: sql.NullString{String: "2026-10-09T17:00:00Z", Valid: true}}
for path, got := range states(t, st, ps) {
if got.failures != want.failures || got.lastSuccess.Valid != want.lastSuccess.Valid ||
!sameInstant(t, got.lastSuccess.String, want.lastSuccess.String) {
t.Errorf("%s: state = %+v, want %+v", path, got, want)
}
}
}
func sameInstant(t *testing.T, a, b string) bool {
t.Helper()
ta, err := time.Parse(time.RFC3339Nano, a)
if err != nil {
t.Fatalf("last_success_at %q is not RFC 3339: %v", a, err)
}
tb, err := time.Parse(time.RFC3339Nano, b)
if err != nil {
t.Fatal(err)
}
return ta.Equal(tb) && strings.HasSuffix(a, "Z")
}
func TestSendRemovesGoneSubscriptions(t *testing.T) {
for _, status := range []int{http.StatusNotFound, http.StatusGone} {
t.Run(http.StatusText(status), func(t *testing.T) {
gone := newDevice(t, "/push/gone")
ps := newPushService(t, map[string]int{gone.path: status})
s, st, _ := newTestSender(t, ps, gone)
err := s.Send(context.Background(), message())
if err == nil {
t.Fatal("Send with only a gone subscription succeeded, want an error so the outbox retries")
}
if strings.Contains(err.Error(), ps.srv.URL) {
t.Errorf("error %q quotes the endpoint", err)
}
if got := states(t, st, ps); len(got) != 0 {
t.Errorf("subscriptions = %v, want the gone one removed", got)
}
})
}
}
func TestSendSucceedsWhenOneDeviceAccepts(t *testing.T) {
ok, gone, broken := newDevice(t, "/push/ok"), newDevice(t, "/push/gone"), newDevice(t, "/push/broken")
ps := newPushService(t, map[string]int{
ok.path: http.StatusCreated, gone.path: http.StatusGone, broken.path: http.StatusInternalServerError,
})
s, st, _ := newTestSender(t, ps, ok, gone, broken)
if err := s.Send(context.Background(), message()); err != nil {
t.Fatalf("Send: %v", err)
}
got := states(t, st, ps)
if len(got) != 2 {
t.Fatalf("subscriptions = %v, want ok and broken kept, gone removed", got)
}
if s := got[ok.path]; !s.lastSuccess.Valid || s.failures != 0 {
t.Errorf("ok = %+v, want last_success_at set and no failures", s)
}
if s := got[broken.path]; s.lastSuccess.Valid || s.failures != 1 {
t.Errorf("broken = %+v, want one failure and no success", s)
}
}
func TestSendServerErrorIsRetryable(t *testing.T) {
broken := newDevice(t, "/push/broken")
ps := newPushService(t, map[string]int{broken.path: http.StatusInternalServerError})
s, st, _ := newTestSender(t, ps, broken)
for want := 1; want <= 2; want++ {
err := s.Send(context.Background(), message())
if err == nil {
t.Fatal("Send with only a failing subscription succeeded, want an error so the outbox retries")
}
if !strings.Contains(err.Error(), "status 500") || strings.Contains(err.Error(), ps.srv.URL) {
t.Errorf("error = %q, want the status and no endpoint", err)
}
got := states(t, st, ps)[broken.path]
if got.failures != want || got.lastSuccess.Valid {
t.Errorf("after send %d: state = %+v, want failure_count %d and no success", want, got, want)
}
}
}
func TestSendTransportErrorDoesNotQuoteEndpoint(t *testing.T) {
d := newDevice(t, "/push/secret-token")
ps := newPushService(t, map[string]int{})
s, st, _ := newTestSender(t, ps, d)
ps.srv.Close()
err := s.Send(context.Background(), message())
if err == nil {
t.Fatal("Send to a closed push service succeeded")
}
if strings.Contains(err.Error(), "secret-token") {
t.Errorf("error %q quotes the endpoint", err)
}
if got := states(t, st, ps)[d.path]; got.failures != 1 {
t.Errorf("state = %+v, want one failure", got)
}
}
func TestSendWithNoSubscriptionsIsSent(t *testing.T) {
ps := newPushService(t, map[string]int{})
s, _, _ := newTestSender(t, ps)
if err := s.Send(context.Background(), message()); err != nil {
t.Fatalf("Send with no subscriptions = %v, want nil", err)
}
if n := len(ps.requests()); n != 0 {
t.Errorf("push service got %d requests, want none", n)
}
}
// TestOutboxDelivery runs the sender under the notify worker and checks the
// outbox row each outcome leaves.
func TestOutboxDelivery(t *testing.T) {
cases := []struct {
name string
status map[string]int // by device path; nil for no devices
wantStatus string
wantError bool
}{
{"delivered", map[string]int{"/push/ok": http.StatusCreated}, "sent", false},
{"server error", map[string]int{"/push/broken": http.StatusInternalServerError}, "pending", true},
{"no subscriptions", nil, "sent", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ps := newPushService(t, tc.status)
var devices []device
for path := range tc.status {
devices = append(devices, newDevice(t, path))
}
s, st, _ := newTestSender(t, ps, devices...)
ctx, cancel := context.WithCancel(context.Background())
if err := household.SeedNotificationPrefs(ctx, st); err != nil {
t.Fatal(err)
}
if _, err := st.Writer.Exec(`UPDATE notification_prefs SET push = 1, chat = 0 WHERE event = 'plan_ready'`); err != nil {
t.Fatal(err)
}
n, err := notify.NewService(st, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
if err := n.Register(notify.ChannelPush, s); err != nil {
t.Fatal(err)
}
if err := n.Start(ctx); err != nil {
t.Fatal(err)
}
defer func() { cancel(); n.Wait() }()
if err := n.Emit(ctx, notify.Event{
Type: notify.PlanReady, Key: "plan_ready:2026-10-12", Title: "This week's plan is ready", URL: "/plan",
}); err != nil {
t.Fatal(err)
}
var status string
var attempts int
var lastError sql.NullString
deadline := time.Now().Add(10 * time.Second)
for {
err := st.Reader.QueryRow(`SELECT status, attempts, last_error FROM notification_outbox
WHERE event_key = 'plan_ready:2026-10-12' AND channel = 'push'`).Scan(&status, &attempts, &lastError)
if err != nil {
t.Fatal(err)
}
if attempts > 0 {
break
}
if time.Now().After(deadline) {
t.Fatal("the worker did not deliver the notification")
}
time.Sleep(10 * time.Millisecond)
}
if status != tc.wantStatus || attempts != 1 || lastError.Valid != tc.wantError {
t.Errorf("outbox row: status %s, attempts %d, last_error %v; want %s, 1, error %v",
status, attempts, lastError, tc.wantStatus, tc.wantError)
}
})
}
}
func TestNewSenderRefusesBadConfig(t *testing.T) {
_, st := newTestService(t)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
pair, err := vapid.Generate()
if err != nil {
t.Fatal(err)
}
other, err := vapid.Generate()
if err != nil {
t.Fatal(err)
}
cases := map[string]struct{ public, private, subject string }{
"bad public key": {"nope", pair.PrivateKey, subject},
"bad private key": {pair.PublicKey, "nope", subject},
"mismatched pair": {pair.PublicKey, other.PrivateKey, subject},
"bad subject": {pair.PublicKey, pair.PrivateKey, "kitchen@example.com"},
"empty mailto": {pair.PublicKey, pair.PrivateKey, "mailto:"},
}
for name, c := range cases {
t.Run(name, func(t *testing.T) {
_, err := NewSender(st, logger, c.public, c.private, c.subject)
if err == nil {
t.Fatal("NewSender succeeded")
}
if strings.Contains(err.Error(), pair.PrivateKey) || strings.Contains(err.Error(), other.PrivateKey) {
t.Errorf("error %q quotes the private key", err)
}
})
}
if _, err := NewSender(st, logger, pair.PublicKey, pair.PrivateKey, "https://kitchen.example.com/contact"); err != nil {
t.Errorf("https subject: %v", err)
}
}
+24
View File
@@ -18,3 +18,27 @@ RETURNING *;
-- name: DeletePushSubscription :execrows
DELETE FROM push_subscriptions
WHERE endpoint = ?;
-- name: ListPushSubscriptions :many
-- Every stored subscription with its keys, for the push sender, oldest
-- first.
SELECT id, endpoint, p256dh, auth
FROM push_subscriptions
ORDER BY created_at, id;
-- name: RecordPushSuccess :exec
-- Records that the push service accepted a notification for a subscription.
UPDATE push_subscriptions
SET last_success_at = ?
WHERE id = ?;
-- name: RecordPushFailure :exec
-- Counts a delivery to a subscription that the push service did not accept.
UPDATE push_subscriptions
SET failure_count = failure_count + 1
WHERE id = ?;
-- name: DeletePushSubscriptionByID :exec
-- Removes a subscription the push service reports gone (404 or 410).
DELETE FROM push_subscriptions
WHERE id = ?;