Compare commits

...
Author SHA1 Message Date
dev_agent 6963eb9b20 Merge remote-tracking branch 'origin/feature/bbd91ead-1e2b-48fb-b577-fa0a3faf5842-notifications-web-push-and-google-chat' into task/push-handlers_fb6dd817-2224-4800-9b7d-8499e856e171
CI / ci (push) Successful in 5m19s
CI / ci (pull_request) Successful in 5m28s
2026-10-09 10:11:18 -04:00
dev_agent 7a2f80cc09 Merge remote-tracking branch 'origin/dev' into HEAD
CI / ci (push) Successful in 5m21s
2026-10-09 10:11:09 -04:00
dev_agentandClaude Opus 5.5 cafea18651 Refuse protocol-relative notification paths and require an observed tap
CI / ci (push) Failing after 4m32s
CI / ci (pull_request) Failing after 4m28s
PUSH-01: appPath now rejects a path that resolves to a leading // after
the URL parser handles dot segments (also %2e), backslashes and stripped
tabs and newlines, and planClick throws if the final URL is off origin.
Regression tests drive openNotification with the attack forms.

PUSH-02: subscribePush no longer asks for notification permission where
navigator.userActivation is unavailable; it throws a typed
PermissionGestureError ('unobservable', or 'inactive' outside a tap).
The test for that path now overrides userActivation directly: the old one
passed undefined into a defaulted parameter and so never exercised it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:19:28 -04:00
dev_agentandClaude Opus 5.5 067e85bd54 Add service worker push handlers and client push subscribe
CI / ci (push) Failing after 7m33s
CI / ci (pull_request) Failing after 4m31s
The worker's push listener shows the payload's title, body and tag with
the app icon and badge, carrying the same-origin url as its data; a
payload that is not a MealQuest notice (no payload, not JSON, no title,
wrong field types, an off-origin url) shows a generic MealQuest
notification and logs why, never throwing. notificationclick focuses
an open MealQuest window and navigates it to the url, or opens a new
window there. Both listeners delegate to pure functions in
web/src/push/notification.ts.

web/src/push/subscribe.ts adds subscribePush, which checks support,
asks for permission only during a tap (and before any await), subscribes
with the server's VAPID key, replacing a subscription made with an
older key, and POSTs exactly {endpoint, keys, user_agent?}; and
unsubscribePush, which DELETEs on the server and then unsubscribes the
browser, answering not-subscribed when there is nothing to remove.
usePush binds both to the app's API client.

Maestro task fb6dd817-2224-4800-9b7d-8499e856e171 (Feature 19).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:48:00 -04:00
orchestrator-agent 3ba2be05e9 VAPID config and push subscription API (Feature 19) (#153)
CI / ci (push) Failing after 6m4s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-09 08:42:12 -04:00
38 changed files with 2557 additions and 36 deletions
+12
View File
@@ -19,6 +19,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
@@ -59,6 +60,17 @@ func loadConfig(t *testing.T, keyFile string) config.Config {
t.Setenv(config.EnvOIDCAllowedGroups, "mealquest-admin")
t.Setenv(config.EnvGoogleClientID, "mealquest.apps.googleusercontent.com")
t.Setenv(config.EnvGoogleClientSecretFile, googleSecretPath)
pair, err := vapid.Generate()
if err != nil {
t.Fatal(err)
}
vapidKeyPath := filepath.Join(dir, "vapid_private_key")
if err := os.WriteFile(vapidKeyPath, []byte(pair.PrivateKey+"\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv(config.EnvVAPIDPublicKey, pair.PublicKey)
t.Setenv(config.EnvVAPIDPrivateKeyFile, vapidKeyPath)
t.Setenv(config.EnvVAPIDSubject, "mailto:owner@example.com")
// MEALQUEST_DEFAULT_TZ is left unset; t.Setenv restores any outer value.
t.Setenv(config.EnvDefaultTZ, "")
if err := os.Unsetenv(config.EnvDefaultTZ); err != nil {
+7 -1
View File
@@ -25,6 +25,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
@@ -138,6 +139,11 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err
}
pushSvc, err := push.NewService(db, cfg.VAPID.PublicKey)
if err != nil {
return err
}
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
if err != nil {
return err
@@ -154,7 +160,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err
}
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, runner, photos, frontend.Assets())
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, runner, photos, frontend.Assets())
if err != nil {
return err
}
+39
View File
@@ -0,0 +1,39 @@
// Command vapidgen prints a new VAPID key pair for Web Push. The public key
// line goes into deploy/.env as is; the private key line goes, alone, into
// the secret file MEALQUEST_VAPID_PRIVATE_KEY_FILE names. Both keys are
// unpadded base64url P-256 keys, the encoding config.Load validates.
//
// go run ./cmd/vapidgen
package main
import (
"fmt"
"io"
"os"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
)
func main() {
if len(os.Args) > 1 {
fmt.Fprintln(os.Stderr, "usage: vapidgen (takes no arguments)")
os.Exit(2)
}
if err := run(os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
// run writes a new key pair to w in four lines: a comment and the public
// key's environment line, then a comment and the private key alone.
func run(w io.Writer) error {
pair, err := vapid.Generate()
if err != nil {
return err
}
_, err = fmt.Fprintf(w, "# Environment (deploy/.env):\n%s=%s\n# Private key: this next line alone is the file %s names.\n%s\n",
config.EnvVAPIDPublicKey, pair.PublicKey, config.EnvVAPIDPrivateKeyFile, pair.PrivateKey)
return err
}
+46
View File
@@ -0,0 +1,46 @@
package main
import (
"strings"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
)
// TestRunPrintsAMatchingPair checks the output shape and that the printed
// private key line is a key config.Load accepts for the printed public key.
func TestRunPrintsAMatchingPair(t *testing.T) {
t.Parallel()
var out strings.Builder
if err := run(&out); err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimSuffix(out.String(), "\n"), "\n")
if len(lines) != 4 || !strings.HasPrefix(lines[0], "# ") || !strings.HasPrefix(lines[2], "# ") {
t.Fatalf("output = %q, want comment, public key, comment, private key", out.String())
}
public, ok := strings.CutPrefix(lines[1], config.EnvVAPIDPublicKey+"=")
if !ok {
t.Fatalf("public key line = %q, want %s=<key>", lines[1], config.EnvVAPIDPublicKey)
}
pub, err := vapid.ParsePublicKey(public)
if err != nil {
t.Fatalf("printed public key: %v", err)
}
priv, err := vapid.ParsePrivateKey(lines[3])
if err != nil {
t.Fatalf("printed private key: %v", err)
}
if err := vapid.CheckPair(pub, priv); err != nil {
t.Error(err)
}
var second strings.Builder
if err := run(&second); err != nil {
t.Fatal(err)
}
if second.String() == out.String() {
t.Error("two runs printed the same key pair")
}
}
+17 -11
View File
@@ -6,7 +6,8 @@
#
# Secrets are never set here. They live in deploy/secrets/ (never committed),
# one value per file, owned by the container user 10001:10001 with mode
# 0600: secret_key, oidc_client_secret and google_client_secret. SearXNG's
# 0600: secret_key, oidc_client_secret, google_client_secret and
# vapid_private_key. SearXNG's
# searxng.env is the exception: compose reads it itself, so it is owned by
# the host account that runs compose, mode 0600. Creating them: CTXD project
# mealquest, RUNBOOKS.MD "Stack and first deploy" > "4. Secrets".
@@ -57,6 +58,19 @@ MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
# URI is <MEALQUEST_PUBLIC_URL origin>/api/v1/integrations/google/callback.
MEALQUEST_GOOGLE_CLIENT_ID=<client id>.apps.googleusercontent.com
# --- Web Push (VAPID) ------------------------------------------------------
# Required: the service will not start without the public key, the subject
# and the private key file, or with a private key that does not belong to
# the public key. `go run ./cmd/vapidgen` prints a new pair: its
# MEALQUEST_VAPID_PUBLIC_KEY line goes here, its private key line alone
# into deploy/secrets/vapid_private_key. Both are unpadded base64url P-256
# keys. Key pair: CTXD project mealquest, RUNBOOKS.MD "VAPID keys (Web
# Push)". Replacing the pair invalidates every browser's subscription.
MEALQUEST_VAPID_PUBLIC_KEY=<base64url VAPID public key>
# The push services' contact for this sender: mailto:<address> or https://.
MEALQUEST_VAPID_SUBJECT=mailto:<owner address>
# Optional session lifetimes. A session ends after IDLE unused and always MAX
# after sign-in; MAX must be at least IDLE. Defaults: 720h and 4320h.
MEALQUEST_SESSION_IDLE=720h
@@ -72,18 +86,10 @@ MEALQUEST_SESSION_MAX=4320h
# The Authentik provider's client secret.
# MEALQUEST_GOOGLE_CLIENT_SECRET_FILE=/run/secrets/google_client_secret
# The Google OAuth web client's secret.
# MEALQUEST_VAPID_PRIVATE_KEY_FILE=/run/secrets/vapid_private_key
# The VAPID private key printed by `go run ./cmd/vapidgen`.
# MEALQUEST_SEARXNG_URL=http://searxng:8080
# The SearXNG recipe-search sidecar on the compose network. The Settings
# screen pre-fills it as the SearXNG URL until one is saved; the saved
# provider and base URL come from PATCH /api/v1/settings/recipe-search.
# See CTXD project mealquest, RUNBOOKS.MD "Recipe search (SearXNG)".
# --- Planned; not read by the service yet -------------------------------------
#
# Added to deploy/compose.yaml and this file by the features that use them.
#
# Web Push (Feature 19). Key pair: CTXD project mealquest, RUNBOOKS.MD
# "VAPID keys (Web Push)".
# MEALQUEST_VAPID_PUBLIC_KEY=<base64url VAPID public key>
# MEALQUEST_VAPID_PRIVATE_KEY_FILE=/run/secrets/vapid_private_key
# MEALQUEST_VAPID_SUBJECT=mailto:<owner address>
+4
View File
@@ -31,11 +31,13 @@ services:
MEALQUEST_SECRET_KEY_FILE: /run/secrets/secret_key
MEALQUEST_OIDC_CLIENT_SECRET_FILE: /run/secrets/oidc_client_secret
MEALQUEST_GOOGLE_CLIENT_SECRET_FILE: /run/secrets/google_client_secret
MEALQUEST_VAPID_PRIVATE_KEY_FILE: /run/secrets/vapid_private_key
MEALQUEST_SEARXNG_URL: http://searxng:8080
secrets:
- secret_key
- oidc_client_secret
- google_client_secret
- vapid_private_key
volumes:
- mealquest-data:/data
ports:
@@ -130,6 +132,8 @@ secrets:
file: ./secrets/oidc_client_secret
google_client_secret:
file: ./secrets/google_client_secret
vapid_private_key:
file: ./secrets/vapid_private_key
volumes:
mealquest-data:
+27
View File
@@ -43,6 +43,7 @@ headers, so sign-in works whichever peer the request arrives from.
| Path | Purpose |
| --- | --- |
| `cmd/server` | Entry point; wiring only. |
| `cmd/vapidgen` | Prints a new VAPID key pair for Web Push. |
| `internal/config` | Env-only `MEALQUEST_*` config; secrets from `*_FILE`. |
| `internal/server` | HTTP server, router, middleware. |
| `internal/frontend` | Embedded PWA, SPA fallback. |
@@ -53,6 +54,7 @@ headers, so sign-in works whichever peer the request arrives from.
| `internal/ai/llm`, `internal/ai/harness`, `internal/ai/skills` | AI provider adapters, skill harness, skills. |
| `internal/suggest`, `internal/plan`, `internal/shopping`, `internal/ideas` | Feature services. |
| `internal/gcal`, `internal/notify`, `internal/push`, `internal/chat` | Integrations. |
| `internal/vapid` | VAPID key generation and validation. |
| `internal/jobs`, `internal/scheduler` | Background jobs and schedules. |
| `migrations/` | SQL migrations (no goose or golang-migrate). |
| `web/` | React PWA. |
@@ -657,6 +659,31 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
- Delivery goes through `notification_outbox`.
- Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming
webhook.
- VAPID configuration (`MEALQUEST_VAPID_PUBLIC_KEY`,
`MEALQUEST_VAPID_PRIVATE_KEY_FILE`, `MEALQUEST_VAPID_SUBJECT`) is required
at boot, all three together. Both keys are unpadded base64url P-256 keys
(`internal/vapid`): the public key the 65-byte uncompressed point, the
private key the 32-byte scalar, read only from its file. Boot derives the
public point from the private key and refuses a pair that does not
match. The subject is `mailto:<address>` or an `https://` URL.
`go run ./cmd/vapidgen` prints a new pair.
- `internal/push` keeps the subscriptions, one row per endpoint, under the
signed-in `/api/v1`:
- `GET /api/v1/push/vapid-public-key` answers `{"public_key": "…"}`, the
browser's `applicationServerKey`. The private key is never served.
- `POST /api/v1/push/subscriptions` (CSRF-checked) takes
`{"endpoint", "keys": {"p256dh", "auth"}, "user_agent"?}` — the
endpoint an `https://` URL, the keys unpadded base64url as
`PushSubscription.toJSON()` gives them (p256dh a P-256 point, auth 16
bytes); unknown fields such as `expirationTime`, and nulls, are
refused. It upserts by endpoint: an endpoint already stored keeps its
id and `created_at`, has its keys and user agent replaced and its
failure count cleared. It answers 200 with
`{id, endpoint, user_agent, created_at, updated_at}`, never the keys.
- `DELETE /api/v1/push/subscriptions` (CSRF-checked) takes
`{"endpoint"}` and answers 204 whether or not it was stored.
- An invalid body is 400 `invalid_request` (not a JSON object of the
known fields) or `invalid_subscription` (a field fails validation).
## Google Calendar
+81
View File
@@ -3,9 +3,11 @@
package config
import (
"crypto/ecdh"
"errors"
"fmt"
"net"
"net/mail"
"net/url"
"os"
"strconv"
@@ -14,6 +16,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
)
// Environment variable names.
@@ -32,6 +35,10 @@ const (
EnvGoogleClientID = "MEALQUEST_GOOGLE_CLIENT_ID"
EnvGoogleClientSecretFile = "MEALQUEST_GOOGLE_CLIENT_SECRET_FILE"
EnvVAPIDPublicKey = "MEALQUEST_VAPID_PUBLIC_KEY"
EnvVAPIDPrivateKeyFile = "MEALQUEST_VAPID_PRIVATE_KEY_FILE"
EnvVAPIDSubject = "MEALQUEST_VAPID_SUBJECT"
EnvSessionIdle = "MEALQUEST_SESSION_IDLE"
EnvSessionMax = "MEALQUEST_SESSION_MAX"
@@ -70,6 +77,9 @@ type Config struct {
GoogleClientID string
GoogleClientSecret Secret
// VAPID is the key pair and contact subject Web Push signs with.
VAPID VAPID
// SessionIdle ends a session left unused this long.
SessionIdle time.Duration
// SessionMax ends every session this long after sign-in, used or not.
@@ -93,6 +103,20 @@ type Config struct {
SearXNGURL string
}
// VAPID is the Web Push signing identity (RFC 8292). All three variables
// are required together, and the private key must belong to the public key.
type VAPID struct {
// PublicKey is the unpadded base64url uncompressed P-256 point, as
// browsers take it for applicationServerKey.
PublicKey string
// PrivateKey is the unpadded base64url 32-byte P-256 scalar, read from
// the file MEALQUEST_VAPID_PRIVATE_KEY_FILE names.
PrivateKey Secret
// Subject is the push services' contact for this sender: a mailto: URI
// or an https:// URL.
Subject string
}
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
// that are safe to log.
func (c Config) PublicOrigin() string {
@@ -122,6 +146,8 @@ func load(lookup func(string) (string, bool)) (Config, error) {
GoogleClientID: r.str(EnvGoogleClientID),
GoogleClientSecret: r.secretFile(EnvGoogleClientSecretFile),
VAPID: r.vapid(),
SessionIdle: r.sessionDuration(EnvSessionIdle, DefaultSessionIdle),
SessionMax: r.sessionDuration(EnvSessionMax, DefaultSessionMax),
@@ -387,6 +413,61 @@ func (r *reader) secretFile(name string) Secret {
return Secret{value: value}
}
// vapid reads the VAPID public key, private key file and subject, each
// required, and checks the private key against the public key by deriving
// its public point. A missing variable is reported by name; the private key
// is never echoed.
func (r *reader) vapid() VAPID {
cfg := VAPID{
PublicKey: r.str(EnvVAPIDPublicKey),
PrivateKey: r.secretFile(EnvVAPIDPrivateKeyFile),
Subject: r.vapidSubject(EnvVAPIDSubject),
}
var public *ecdh.PublicKey
if cfg.PublicKey != "" {
k, err := vapid.ParsePublicKey(cfg.PublicKey)
if err != nil {
r.fail(EnvVAPIDPublicKey, "invalid VAPID public key: %v", err)
}
public = k
}
var private *ecdh.PrivateKey
if cfg.PrivateKey.Reveal() != "" {
k, err := vapid.ParsePrivateKey(cfg.PrivateKey.Reveal())
if err != nil {
r.fail(EnvVAPIDPrivateKeyFile, "invalid VAPID private key in secret file: %v", err)
}
private = k
}
if public != nil && private != nil && vapid.CheckPair(public, private) != nil {
r.fail(EnvVAPIDPrivateKeyFile, "VAPID private key does not match %s", EnvVAPIDPublicKey)
}
return cfg
}
// vapidSubject reads a VAPID subject: mailto: followed by one email
// address, or an absolute https:// URL.
func (r *reader) vapidSubject(name string) string {
v, ok := r.required(name)
if !ok {
return ""
}
if addr, found := strings.CutPrefix(v, "mailto:"); found {
parsed, err := mail.ParseAddress(addr)
if err != nil || parsed.Address != addr {
r.fail(name, "invalid mailto: subject (want mailto:<email address>)")
return ""
}
return v
}
u, err := url.Parse(v)
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil {
r.fail(name, "invalid subject (want mailto:<email address> or an https:// URL)")
return ""
}
return v
}
func unwrapPathError(err error) error {
var pe *os.PathError
if errors.As(err, &pe) {
+171 -1
View File
@@ -14,8 +14,24 @@ import (
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
)
// writeVAPIDKey writes a new VAPID key pair's private key to dir/name, as
// cmd/vapidgen tells the operator to, and returns the pair.
func writeVAPIDKey(t *testing.T, dir, name string) (vapid.KeyPair, string) {
t.Helper()
pair, err := vapid.Generate()
if err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(pair.PrivateKey+"\n"), 0o600); err != nil {
t.Fatal(err)
}
return pair, path
}
func TestLoad(t *testing.T) {
t.Parallel()
@@ -37,6 +53,16 @@ func TestLoad(t *testing.T) {
if err := os.WriteFile(emptyPath, []byte("\n"), 0o600); err != nil {
t.Fatal(err)
}
vapidPair, vapidKeyPath := writeVAPIDKey(t, dir, "vapid_private_key")
otherPair, otherKeyPath := writeVAPIDKey(t, dir, "other_vapid_private_key")
pemKeyPath := filepath.Join(dir, "vapid_private_key.pem")
if err := os.WriteFile(pemKeyPath, []byte("-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIA==\n-----END EC PRIVATE KEY-----\n"), 0o600); err != nil {
t.Fatal(err)
}
paddedKeyPath := filepath.Join(dir, "padded_vapid_private_key")
if err := os.WriteFile(paddedKeyPath, []byte(vapidPair.PrivateKey+"=\n"), 0o600); err != nil {
t.Fatal(err)
}
valid := func() map[string]string {
return map[string]string{
@@ -53,6 +79,10 @@ func TestLoad(t *testing.T) {
EnvGoogleClientID: "mealquest.apps.googleusercontent.com",
EnvGoogleClientSecretFile: googleSecretPath,
EnvVAPIDPublicKey: vapidPair.PublicKey,
EnvVAPIDPrivateKeyFile: vapidKeyPath,
EnvVAPIDSubject: "mailto:owner@example.com",
}
}
with := func(changes map[string]string, unset ...string) map[string]string {
@@ -102,8 +132,108 @@ func TestLoad(t *testing.T) {
EnvHTTPAddr, EnvPublicURL, EnvDataDir, EnvShutdownTimeout, EnvSecretKeyFile,
EnvOIDCIssuer, EnvOIDCClientID, EnvOIDCClientSecretFile, EnvOIDCAllowedGroups,
EnvGoogleClientID, EnvGoogleClientSecretFile,
EnvVAPIDPublicKey, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject,
},
},
{
name: "VAPID public key missing",
env: with(nil, EnvVAPIDPublicKey),
badVars: []string{EnvVAPIDPublicKey},
},
{
name: "VAPID private key file missing",
env: with(nil, EnvVAPIDPrivateKeyFile),
badVars: []string{EnvVAPIDPrivateKeyFile},
},
{
name: "VAPID subject missing",
env: with(nil, EnvVAPIDSubject),
badVars: []string{EnvVAPIDSubject},
},
{
name: "only the VAPID public key set",
env: with(nil, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject),
badVars: []string{EnvVAPIDPrivateKeyFile, EnvVAPIDSubject},
},
{
name: "no VAPID variable set",
env: with(nil, EnvVAPIDPublicKey, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject),
badVars: []string{EnvVAPIDPublicKey, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject},
},
{
name: "VAPID private key file unreadable",
env: with(map[string]string{EnvVAPIDPrivateKeyFile: unreadablePath}),
badVars: []string{EnvVAPIDPrivateKeyFile},
},
{
name: "VAPID private key file empty",
env: with(map[string]string{EnvVAPIDPrivateKeyFile: emptyPath}),
badVars: []string{EnvVAPIDPrivateKeyFile},
},
{
name: "inline VAPID private key rejected",
env: with(map[string]string{"MEALQUEST_VAPID_PRIVATE_KEY": vapidPair.PrivateKey}),
badVars: []string{"MEALQUEST_VAPID_PRIVATE_KEY"},
},
{
name: "VAPID private key as PEM",
env: with(map[string]string{EnvVAPIDPrivateKeyFile: pemKeyPath}),
badVars: []string{EnvVAPIDPrivateKeyFile},
},
{
name: "padded VAPID private key",
env: with(map[string]string{EnvVAPIDPrivateKeyFile: paddedKeyPath}),
badVars: []string{EnvVAPIDPrivateKeyFile},
},
{
name: "VAPID private key from another pair",
env: with(map[string]string{EnvVAPIDPrivateKeyFile: otherKeyPath}),
badVars: []string{EnvVAPIDPrivateKeyFile},
},
{
name: "VAPID public key from another pair",
env: with(map[string]string{EnvVAPIDPublicKey: otherPair.PublicKey}),
badVars: []string{EnvVAPIDPrivateKeyFile},
},
{
name: "VAPID public key malformed",
env: with(map[string]string{EnvVAPIDPublicKey: "not-a-key"}),
badVars: []string{EnvVAPIDPublicKey},
},
{
name: "VAPID public key padded",
env: with(map[string]string{EnvVAPIDPublicKey: vapidPair.PublicKey + "="}),
badVars: []string{EnvVAPIDPublicKey},
},
{
name: "VAPID private key given as the public key",
env: with(map[string]string{EnvVAPIDPublicKey: vapidPair.PrivateKey}),
badVars: []string{EnvVAPIDPublicKey},
},
{
name: "VAPID https subject",
env: with(map[string]string{EnvVAPIDSubject: "https://meals.example.com/contact"}),
},
{
name: "VAPID subject without mailto:",
env: with(map[string]string{EnvVAPIDSubject: "owner@example.com"}),
badVars: []string{EnvVAPIDSubject},
},
{
name: "VAPID mailto: subject without an address",
env: with(map[string]string{EnvVAPIDSubject: "mailto:owner"}),
badVars: []string{EnvVAPIDSubject},
},
{
name: "VAPID mailto: subject with a display name",
env: with(map[string]string{EnvVAPIDSubject: "mailto:Owner <owner@example.com>"}),
badVars: []string{EnvVAPIDSubject},
},
{
name: "VAPID plain http subject",
env: with(map[string]string{EnvVAPIDSubject: "http://meals.example.com"}),
badVars: []string{EnvVAPIDSubject},
},
{
name: "invalid duration",
env: with(map[string]string{EnvShutdownTimeout: "ten seconds"}),
@@ -446,6 +576,7 @@ func TestLoadValues(t *testing.T) {
if err := os.WriteFile(googleSecretPath, []byte("google-s3cret\n"), 0o600); err != nil {
t.Fatal(err)
}
vapidPair, vapidKeyPath := writeVAPIDKey(t, dir, "vapid_private_key")
env := map[string]string{
EnvHTTPAddr: "127.0.0.1:9000",
EnvPublicURL: "http://localhost:9000",
@@ -461,6 +592,10 @@ func TestLoadValues(t *testing.T) {
EnvGoogleClientID: "mealquest.apps.googleusercontent.com",
EnvGoogleClientSecretFile: googleSecretPath,
EnvVAPIDPublicKey: vapidPair.PublicKey,
EnvVAPIDPrivateKeyFile: vapidKeyPath,
EnvVAPIDSubject: "mailto:owner@example.com",
EnvDefaultTZ: "Europe/London",
EnvSearXNGURL: "http://searxng:8080",
@@ -500,6 +635,9 @@ func TestLoadValues(t *testing.T) {
if cfg.GoogleClientSecret.Reveal() != "google-s3cret" {
t.Errorf("GoogleClientSecret.Reveal() = %q", cfg.GoogleClientSecret.Reveal())
}
if want := (VAPID{PublicKey: vapidPair.PublicKey, PrivateKey: Secret{value: vapidPair.PrivateKey}, Subject: "mailto:owner@example.com"}); cfg.VAPID != want {
t.Errorf("VAPID = %+v, want public key, trimmed private key and subject", cfg.VAPID)
}
if want := []string{"mealquest-admin", "family"}; !slices.Equal(cfg.OIDCAllowedGroups, want) {
t.Errorf("OIDCAllowedGroups = %q, want %q", cfg.OIDCAllowedGroups, want)
}
@@ -538,7 +676,7 @@ func TestSecretRedacted(t *testing.T) {
s := Secret{value: "s3cret-value"}
for _, format := range []string{"%v", "%s", "%+v", "%#v"} {
cfg := Config{SecretKey: s, OIDCClientSecret: s, GoogleClientSecret: s}
cfg := Config{SecretKey: s, OIDCClientSecret: s, GoogleClientSecret: s, VAPID: VAPID{PrivateKey: s}}
for _, out := range []string{fmt.Sprintf(format, s), fmt.Sprintf(format, cfg)} {
if strings.Contains(out, "s3cret-value") {
t.Errorf("format %s leaked secret: %s", format, out)
@@ -547,6 +685,38 @@ func TestSecretRedacted(t *testing.T) {
}
}
// TestVAPIDErrorsNeverQuoteThePrivateKey checks every VAPID private key
// problem names the variable without echoing the file's contents.
func TestVAPIDErrorsNeverQuoteThePrivateKey(t *testing.T) {
t.Parallel()
dir := t.TempDir()
pair, _ := writeVAPIDKey(t, dir, "unused")
other, err := vapid.Generate()
if err != nil {
t.Fatal(err)
}
for name, contents := range map[string]string{
"mismatched": other.PrivateKey,
"padded": pair.PrivateKey + "=",
"truncated": pair.PrivateKey[:40],
} {
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(contents+"\n"), 0o600); err != nil {
t.Fatal(err)
}
env := map[string]string{EnvVAPIDPublicKey: pair.PublicKey, EnvVAPIDPrivateKeyFile: path, EnvVAPIDSubject: "mailto:owner@example.com"}
_, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
var cerr *Error
if !errors.As(err, &cerr) || !slices.ContainsFunc(cerr.Problems, func(p Problem) bool { return p.Var == EnvVAPIDPrivateKeyFile }) {
t.Errorf("%s: error = %v, want problem for %s", name, err, EnvVAPIDPrivateKeyFile)
}
if err != nil && (strings.Contains(err.Error(), contents) || strings.Contains(err.Error(), contents[:16])) {
t.Errorf("%s: error quotes the private key: %v", name, err)
}
}
}
func TestPublicURLWithCredentialsRejected(t *testing.T) {
t.Parallel()
+77
View File
@@ -0,0 +1,77 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: push_subscriptions.sql
package db
import (
"context"
"database/sql"
)
const deletePushSubscription = `-- name: DeletePushSubscription :execrows
DELETE FROM push_subscriptions
WHERE endpoint = ?
`
func (q *Queries) DeletePushSubscription(ctx context.Context, endpoint string) (int64, error) {
result, err := q.db.ExecContext(ctx, deletePushSubscription, endpoint)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const upsertPushSubscription = `-- name: UpsertPushSubscription :one
INSERT INTO push_subscriptions (
id, endpoint, p256dh, auth, user_agent, failure_count, created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, 0, ?, ?
)
ON CONFLICT (endpoint) DO UPDATE SET
p256dh = excluded.p256dh,
auth = excluded.auth,
user_agent = excluded.user_agent,
failure_count = 0,
updated_at = excluded.updated_at
RETURNING id, endpoint, p256dh, auth, user_agent, failure_count, last_success_at, created_at, updated_at
`
type UpsertPushSubscriptionParams struct {
ID string
Endpoint string
P256dh string
Auth string
UserAgent sql.NullString
CreatedAt string
UpdatedAt string
}
// Stores a browser's push subscription, keyed by its endpoint. Subscribing
// an endpoint already stored replaces its keys and user agent and clears
// its failure count, keeping its id, created_at and last_success_at.
func (q *Queries) UpsertPushSubscription(ctx context.Context, arg UpsertPushSubscriptionParams) (PushSubscription, error) {
row := q.db.QueryRowContext(ctx, upsertPushSubscription,
arg.ID,
arg.Endpoint,
arg.P256dh,
arg.Auth,
arg.UserAgent,
arg.CreatedAt,
arg.UpdatedAt,
)
var i PushSubscription
err := row.Scan(
&i.ID,
&i.Endpoint,
&i.P256dh,
&i.Auth,
&i.UserAgent,
&i.FailureCount,
&i.LastSuccessAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
+5
View File
@@ -62,6 +62,7 @@ type Querier interface {
DecideSuggestion(ctx context.Context, arg DecideSuggestionParams) (int64, error)
DeleteAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
DeleteIntegrationsGoogle(ctx context.Context) error
DeletePushSubscription(ctx context.Context, endpoint string) (int64, error)
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
DeleteRecipeSteps(ctx context.Context, recipeID string) error
DeleteShoppingGroup(ctx context.Context, id string) error
@@ -276,6 +277,10 @@ type Querier interface {
// Creates the single row (id 1) or replaces every column except id and
// created_at.
UpsertIntegrationsGoogle(ctx context.Context, arg UpsertIntegrationsGoogleParams) (IntegrationsGoogle, error)
// Stores a browser's push subscription, keyed by its endpoint. Subscribing
// an endpoint already stored replaces its keys and user agent and clears
// its failure count, keeping its id, created_at and last_success_at.
UpsertPushSubscription(ctx context.Context, arg UpsertPushSubscriptionParams) (PushSubscription, error)
// A recipe is keyed by source_url: re-fetching the same URL updates the
// existing row and keeps its id and created_at, which the returned row
// carries. Replace its ingredients and steps in the same transaction with
+203
View File
@@ -0,0 +1,203 @@
// Package push keeps the household's Web Push subscriptions, one per
// browser, keyed by the push service endpoint the browser was given, and
// the VAPID public key browsers subscribe with.
package push
import (
"context"
"crypto/ecdh"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"net/url"
"strings"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
)
// Limits on a subscription's fields.
const (
MaxEndpointBytes = 2048
MaxUserAgentBytes = 512
p256dhSize = 65 // uncompressed P-256 point
authSize = 16
)
// keyEncoding is the unpadded base64url PushSubscription.toJSON() gives
// the keys in.
var keyEncoding = base64.RawURLEncoding
// Subscription is a stored push subscription. Its keys are never returned:
// only the sender needs them.
type Subscription struct {
ID string `json:"id"`
Endpoint string `json:"endpoint"`
UserAgent *string `json:"user_agent"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// SubscribeRequest is a browser's push subscription: the endpoint and keys
// of its PushSubscription, and optionally a user agent to tell the
// household's devices apart.
type SubscribeRequest struct {
Endpoint string
P256dh string
Auth string
UserAgent *string
}
// Problem is one invalid field of a request, named by its JSON key.
type Problem struct {
Field string
Reason string
}
// ValidationError lists every invalid field of a request.
type ValidationError struct {
Problems []Problem
}
func (e *ValidationError) Error() string {
parts := make([]string, len(e.Problems))
for i, p := range e.Problems {
parts[i] = p.Field + ": " + p.Reason
}
return "invalid push subscription: " + strings.Join(parts, "; ")
}
// Service stores push subscriptions.
type Service struct {
st *store.Store
publicKey string
}
// NewService returns a Service over st handing out publicKey, the VAPID
// public key in vapid's encoding.
func NewService(st *store.Store, publicKey string) (*Service, error) {
if st == nil {
return nil, errors.New("push: nil store")
}
if _, err := vapid.ParsePublicKey(publicKey); err != nil {
return nil, fmt.Errorf("push: VAPID public key: %w", err)
}
return &Service{st: st, publicKey: publicKey}, nil
}
// PublicKey returns the VAPID public key, for applicationServerKey.
func (s *Service) PublicKey() string { return s.publicKey }
// Subscribe stores req by its endpoint. An endpoint already stored keeps
// its id and created_at and has its keys and user agent replaced, so
// subscribing twice leaves one subscription. An invalid request is a
// *ValidationError.
func (s *Service) Subscribe(ctx context.Context, req SubscribeRequest) (Subscription, error) {
var problems []Problem
if reason := checkEndpoint(req.Endpoint); reason != "" {
problems = append(problems, Problem{"endpoint", reason})
}
if reason := checkP256dh(req.P256dh); reason != "" {
problems = append(problems, Problem{"keys.p256dh", reason})
}
if reason := checkAuth(req.Auth); reason != "" {
problems = append(problems, Problem{"keys.auth", reason})
}
var ua sql.NullString
if req.UserAgent != nil {
if reason := checkUserAgent(*req.UserAgent); reason != "" {
problems = append(problems, Problem{"user_agent", reason})
}
ua = sql.NullString{String: *req.UserAgent, Valid: true}
}
if len(problems) > 0 {
return Subscription{}, &ValidationError{Problems: problems}
}
now := store.Now().String()
row, err := db.New(s.st.Writer).UpsertPushSubscription(ctx, db.UpsertPushSubscriptionParams{
ID: store.NewID(),
Endpoint: req.Endpoint,
P256dh: req.P256dh,
Auth: req.Auth,
UserAgent: ua,
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return Subscription{}, fmt.Errorf("push: store subscription: %w", err)
}
sub := Subscription{ID: row.ID, Endpoint: row.Endpoint, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
if row.UserAgent.Valid {
sub.UserAgent = &row.UserAgent.String
}
return sub, nil
}
// Unsubscribe removes the subscription for endpoint and reports whether
// one was stored. An endpoint that is not stored is not an error: the
// browser may unsubscribe after a failed delivery already removed it. An
// invalid endpoint is a *ValidationError.
func (s *Service) Unsubscribe(ctx context.Context, endpoint string) (bool, error) {
if reason := checkEndpoint(endpoint); reason != "" {
return false, &ValidationError{Problems: []Problem{{"endpoint", reason}}}
}
n, err := db.New(s.st.Writer).DeletePushSubscription(ctx, endpoint)
if err != nil {
return false, fmt.Errorf("push: delete subscription: %w", err)
}
return n > 0, nil
}
// checkEndpoint returns why endpoint is not a push service URL, or "".
// Push services are always reached over https.
func checkEndpoint(endpoint string) string {
if endpoint == "" {
return "required"
}
if len(endpoint) > MaxEndpointBytes {
return fmt.Sprintf("longer than %d bytes", MaxEndpointBytes)
}
u, err := url.Parse(endpoint)
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Fragment != "" {
return "want an absolute https:// URL without credentials or fragment"
}
return ""
}
func checkP256dh(s string) string {
if s == "" {
return "required"
}
b, err := keyEncoding.DecodeString(s)
if err != nil || len(b) != p256dhSize {
return fmt.Sprintf("want the %d-byte uncompressed P-256 point in unpadded base64url", p256dhSize)
}
if _, err := ecdh.P256().NewPublicKey(b); err != nil {
return "not a point on P-256"
}
return ""
}
func checkAuth(s string) string {
if s == "" {
return "required"
}
b, err := keyEncoding.DecodeString(s)
if err != nil || len(b) != authSize {
return fmt.Sprintf("want %d bytes in unpadded base64url", authSize)
}
return ""
}
func checkUserAgent(s string) string {
switch {
case strings.TrimSpace(s) == "":
return "must not be blank; omit it instead"
case len(s) > MaxUserAgentBytes:
return fmt.Sprintf("longer than %d bytes", MaxUserAgentBytes)
}
return ""
}
+224
View File
@@ -0,0 +1,224 @@
package push
import (
"context"
"crypto/ecdh"
"crypto/rand"
"encoding/base64"
"errors"
"strings"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
const endpoint = "https://fcm.googleapis.com/fcm/send/abc123"
func newTestService(t *testing.T) (*Service, *store.Store) {
t.Helper()
ctx := context.Background()
st, err := store.Open(ctx, t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := st.Close(); err != nil {
t.Error(err)
}
})
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
pair, err := vapid.Generate()
if err != nil {
t.Fatal(err)
}
svc, err := NewService(st, pair.PublicKey)
if err != nil {
t.Fatal(err)
}
return svc, st
}
// browserKeys returns a p256dh and auth as a browser's
// PushSubscription.toJSON() gives them.
func browserKeys(t *testing.T) (string, string) {
t.Helper()
k, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
auth := make([]byte, 16)
rand.Read(auth)
return base64.RawURLEncoding.EncodeToString(k.PublicKey().Bytes()), base64.RawURLEncoding.EncodeToString(auth)
}
type storedRow struct {
id, p256dh, auth, createdAt, updatedAt string
ua *string
failures int
}
func rows(t *testing.T, st *store.Store) map[string]storedRow {
t.Helper()
rs, err := st.Reader.Query(`SELECT endpoint, id, p256dh, auth, user_agent, failure_count, created_at, updated_at FROM push_subscriptions`)
if err != nil {
t.Fatal(err)
}
defer rs.Close()
got := map[string]storedRow{}
for rs.Next() {
var ep string
var r storedRow
if err := rs.Scan(&ep, &r.id, &r.p256dh, &r.auth, &r.ua, &r.failures, &r.createdAt, &r.updatedAt); err != nil {
t.Fatal(err)
}
got[ep] = r
}
if err := rs.Err(); err != nil {
t.Fatal(err)
}
return got
}
func TestNewServiceRefusesBadPublicKey(t *testing.T) {
t.Parallel()
_, st := newTestService(t)
if _, err := NewService(st, "not-a-key"); err == nil {
t.Error("NewService accepted a malformed public key")
}
if _, err := NewService(nil, ""); err == nil {
t.Error("NewService accepted a nil store")
}
}
func TestSubscribeUpsertsByEndpoint(t *testing.T) {
t.Parallel()
svc, st := newTestService(t)
ctx := context.Background()
p1, a1 := browserKeys(t)
ua := "Mozilla/5.0 (iPad)"
first, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: endpoint, P256dh: p1, Auth: a1, UserAgent: &ua})
if err != nil {
t.Fatal(err)
}
if first.ID == "" || first.Endpoint != endpoint || first.UserAgent == nil || *first.UserAgent != ua || first.CreatedAt != first.UpdatedAt {
t.Errorf("first Subscribe = %+v", first)
}
if _, err := st.Writer.Exec(`UPDATE push_subscriptions SET failure_count = 3`); err != nil {
t.Fatal(err)
}
// The same endpoint again, with the same keys and then rotated ones,
// updates the one row.
again, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: endpoint, P256dh: p1, Auth: a1, UserAgent: &ua})
if err != nil {
t.Fatal(err)
}
p2, a2 := browserKeys(t)
rotated, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: endpoint, P256dh: p2, Auth: a2})
if err != nil {
t.Fatal(err)
}
for _, s := range []Subscription{again, rotated} {
if s.ID != first.ID || s.CreatedAt != first.CreatedAt {
t.Errorf("resubscribe = %+v, want id %s and created_at %s kept", s, first.ID, first.CreatedAt)
}
}
if rotated.UserAgent != nil {
t.Errorf("resubscribe without user_agent kept %q", *rotated.UserAgent)
}
got := rows(t, st)
if len(got) != 1 {
t.Fatalf("rows = %d, want 1: %+v", len(got), got)
}
r := got[endpoint]
if r.p256dh != p2 || r.auth != a2 || r.ua != nil || r.failures != 0 || r.updatedAt < r.createdAt {
t.Errorf("stored row = %+v, want rotated keys, no user agent, failures cleared", r)
}
// A second browser is a second row.
other := endpoint + "-other"
if _, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: other, P256dh: p1, Auth: a1}); err != nil {
t.Fatal(err)
}
if got := rows(t, st); len(got) != 2 {
t.Errorf("rows after a second endpoint = %d, want 2", len(got))
}
}
func TestUnsubscribe(t *testing.T) {
t.Parallel()
svc, st := newTestService(t)
ctx := context.Background()
p, a := browserKeys(t)
for _, ep := range []string{endpoint, endpoint + "-keep"} {
if _, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: ep, P256dh: p, Auth: a}); err != nil {
t.Fatal(err)
}
}
removed, err := svc.Unsubscribe(ctx, endpoint)
if err != nil || !removed {
t.Errorf("Unsubscribe = %v, %v; want true, nil", removed, err)
}
got := rows(t, st)
if _, ok := got[endpoint]; ok || len(got) != 1 {
t.Errorf("rows after Unsubscribe = %+v, want only the other endpoint", got)
}
removed, err = svc.Unsubscribe(ctx, endpoint)
if err != nil || removed {
t.Errorf("Unsubscribe of an endpoint not stored = %v, %v; want false, nil", removed, err)
}
var invalid *ValidationError
if _, err := svc.Unsubscribe(ctx, "http://push.example.com/x"); !errors.As(err, &invalid) {
t.Errorf("Unsubscribe of an http endpoint = %v, want *ValidationError", err)
}
}
func TestSubscribeValidates(t *testing.T) {
t.Parallel()
svc, st := newTestService(t)
p, a := browserKeys(t)
pair, err := vapid.Generate()
if err != nil {
t.Fatal(err)
}
blank, long := " ", strings.Repeat("x", MaxUserAgentBytes+1)
offCurve := make([]byte, 65)
offCurve[0] = 0x04
raw := base64.RawURLEncoding.EncodeToString
for _, tc := range []struct {
name string
req SubscribeRequest
field string
}{
{"missing endpoint", SubscribeRequest{P256dh: p, Auth: a}, "endpoint"},
{"http endpoint", SubscribeRequest{Endpoint: "http://push.example.com/x", P256dh: p, Auth: a}, "endpoint"},
{"relative endpoint", SubscribeRequest{Endpoint: "/fcm/send/x", P256dh: p, Auth: a}, "endpoint"},
{"endpoint with credentials", SubscribeRequest{Endpoint: "https://u:p@push.example.com/x", P256dh: p, Auth: a}, "endpoint"},
{"endpoint too long", SubscribeRequest{Endpoint: "https://push.example.com/" + strings.Repeat("x", MaxEndpointBytes), P256dh: p, Auth: a}, "endpoint"},
{"missing p256dh", SubscribeRequest{Endpoint: endpoint, Auth: a}, "keys.p256dh"},
{"padded p256dh", SubscribeRequest{Endpoint: endpoint, P256dh: p + "=", Auth: a}, "keys.p256dh"},
{"short p256dh", SubscribeRequest{Endpoint: endpoint, P256dh: a, Auth: a}, "keys.p256dh"},
{"p256dh off the curve", SubscribeRequest{Endpoint: endpoint, P256dh: raw(offCurve), Auth: a}, "keys.p256dh"},
{"private key as p256dh", SubscribeRequest{Endpoint: endpoint, P256dh: pair.PrivateKey, Auth: a}, "keys.p256dh"},
{"missing auth", SubscribeRequest{Endpoint: endpoint, P256dh: p}, "keys.auth"},
{"long auth", SubscribeRequest{Endpoint: endpoint, P256dh: p, Auth: raw(make([]byte, 32))}, "keys.auth"},
{"blank user agent", SubscribeRequest{Endpoint: endpoint, P256dh: p, Auth: a, UserAgent: &blank}, "user_agent"},
{"long user agent", SubscribeRequest{Endpoint: endpoint, P256dh: p, Auth: a, UserAgent: &long}, "user_agent"},
} {
_, err := svc.Subscribe(context.Background(), tc.req)
var invalid *ValidationError
if !errors.As(err, &invalid) || len(invalid.Problems) != 1 || invalid.Problems[0].Field != tc.field {
t.Errorf("%s: Subscribe error = %v, want one problem for %s", tc.name, err, tc.field)
}
}
if got := rows(t, st); len(got) != 0 {
t.Errorf("invalid requests stored %d rows", len(got))
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ func newAIRouter(t *testing.T) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t),
newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -92,7 +92,7 @@ func newDinnersEnv(t *testing.T) *dinnersEnv {
t.Fatal(err)
}
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -69,7 +69,7 @@ func newGoogleEnv(t *testing.T) *googleEnv {
}
logger := slog.New(slog.NewJSONHandler(e.logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestRunner(t), newTestPhotos(t),
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestPush(t), newTestRunner(t), newTestPhotos(t),
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+1 -1
View File
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
t.Fatal(err)
}
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t),
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t),
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+1 -1
View File
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
}
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -94,7 +94,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
t.Fatal(err)
}
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+98
View File
@@ -0,0 +1,98 @@
package server
import (
"errors"
"log/slog"
"net/http"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push"
)
// maxPushBody bounds a POST or DELETE /push/subscriptions body: an endpoint
// of push.MaxEndpointBytes, the keys and a user agent of
// push.MaxUserAgentBytes, with room for JSON escaping.
const maxPushBody = 8 << 10
type vapidPublicKeyResponse struct {
PublicKey string `json:"public_key"`
}
// pushSubscriptionBody is the POST /push/subscriptions body: the
// endpoint and keys of PushSubscription.toJSON(), without its
// expirationTime, and an optional user_agent.
type pushSubscriptionBody struct {
Endpoint string `json:"endpoint"`
Keys pushSubscriptionKeys `json:"keys"`
UserAgent *string `json:"user_agent"`
}
type pushSubscriptionKeys struct {
P256dh string `json:"p256dh"`
Auth string `json:"auth"`
}
// pushUnsubscribeBody is the DELETE /push/subscriptions body.
type pushUnsubscribeBody struct {
Endpoint string `json:"endpoint"`
}
// getVAPIDPublicKey answers GET /push/vapid-public-key with the key
// browsers pass as applicationServerKey. The private key is never served.
func getVAPIDPublicKey(logger *slog.Logger, svc *push.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, r, logger, http.StatusOK, vapidPublicKeyResponse{PublicKey: svc.PublicKey()})
}
}
// postPushSubscription stores the browser's subscription by its endpoint
// and answers 200 with it, without its keys. Posting an endpoint already
// stored updates it.
func postPushSubscription(logger *slog.Logger, svc *push.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := decodePatch[pushSubscriptionBody](http.MaxBytesReader(w, r.Body, maxPushBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
sub, err := svc.Subscribe(r.Context(), push.SubscribeRequest{
Endpoint: body.Endpoint,
P256dh: body.Keys.P256dh,
Auth: body.Keys.Auth,
UserAgent: body.UserAgent,
})
if err != nil {
writePushError(w, r, logger, "store push subscription", err)
return
}
writeJSON(w, r, logger, http.StatusOK, sub)
}
}
// deletePushSubscription removes the subscription for the body's endpoint
// and answers 204, whether or not it was stored.
func deletePushSubscription(logger *slog.Logger, svc *push.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := decodePatch[pushUnsubscribeBody](http.MaxBytesReader(w, r.Body, maxPushBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if _, err := svc.Unsubscribe(r.Context(), body.Endpoint); err != nil {
writePushError(w, r, logger, "delete push subscription", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// writePushError maps a push.Service error to its API error: an invalid
// subscription is 400 invalid_subscription, anything else 500.
func writePushError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
var invalid *push.ValidationError
if errors.As(err, &invalid) {
writeError(w, r, logger, http.StatusBadRequest, "invalid_subscription", invalid.Error())
return
}
logger.ErrorContext(r.Context(), action, "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
}
+284
View File
@@ -0,0 +1,284 @@
package server
import (
"crypto/ecdh"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
)
const (
vapidPublicKeyPath = "/api/v1/push/vapid-public-key"
pushSubscriptionsPath = "/api/v1/push/subscriptions"
testPushEndpoint = "https://updates.push.services.mozilla.com/wpush/v2/gAAAAABk"
)
// newTestPush returns a push service over a migrated temp database with a
// new VAPID key pair.
func newTestPush(t *testing.T) *push.Service {
t.Helper()
svc, _, _ := newTestPushWith(t)
return svc
}
// newTestPushWith is newTestPush also returning its store and key pair.
func newTestPushWith(t *testing.T) (*push.Service, *store.Store, vapid.KeyPair) {
t.Helper()
pair, err := vapid.Generate()
if err != nil {
t.Fatal(err)
}
st := newTestStore(t)
svc, err := push.NewService(st, pair.PublicKey)
if err != nil {
t.Fatal(err)
}
return svc, st, pair
}
// newPushRouter returns a router serving svc.
func newPushRouter(t *testing.T, svc *push.Service) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), svc, newTestRunner(t),
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
return r
}
// subscriptionJSON returns a POST /push/subscriptions body for endpoint
// with new browser keys.
func subscriptionJSON(t *testing.T, endpoint, userAgent string) string {
t.Helper()
k, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
auth := make([]byte, 16)
rand.Read(auth)
body := map[string]any{
"endpoint": endpoint,
"keys": map[string]string{
"p256dh": base64.RawURLEncoding.EncodeToString(k.PublicKey().Bytes()),
"auth": base64.RawURLEncoding.EncodeToString(auth),
},
}
if userAgent != "" {
body["user_agent"] = userAgent
}
b, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
return string(b)
}
func countPushSubscriptions(t *testing.T, st *store.Store) int {
t.Helper()
var n int
if err := st.Reader.QueryRow(`SELECT count(*) FROM push_subscriptions`).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
func TestNewRouterRequiresPush(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil push service")
}
}
func TestPushAPI(t *testing.T) {
t.Parallel()
svc, st, pair := newTestPushWith(t)
router := newPushRouter(t, svc)
var bodies []string
call := func(method, target, body string) *httptest.ResponseRecorder {
t.Helper()
var r io.Reader
if body != "" {
r = strings.NewReader(body)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, signedIn(method, target, r))
bodies = append(bodies, rec.Body.String())
return rec
}
decode := func(rec *httptest.ResponseRecorder) map[string]any {
t.Helper()
var m map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil {
t.Fatalf("decode %q: %v", rec.Body.String(), err)
}
return m
}
rec := call(http.MethodGet, vapidPublicKeyPath, "")
if body := decode(rec); rec.Code != http.StatusOK || len(body) != 1 || body["public_key"] != pair.PublicKey {
t.Errorf("GET public key = %d %s, want only the public key", rec.Code, rec.Body.String())
}
// Posting the same endpoint twice keeps one subscription, updated.
rec = call(http.MethodPost, pushSubscriptionsPath, subscriptionJSON(t, testPushEndpoint, "Firefox on the kitchen tablet"))
first := decode(rec)
if rec.Code != http.StatusOK || first["endpoint"] != testPushEndpoint || first["user_agent"] != "Firefox on the kitchen tablet" || first["id"] == "" {
t.Errorf("POST subscription = %d %s", rec.Code, rec.Body.String())
}
if _, ok := first["keys"]; ok {
t.Errorf("POST response holds the keys: %s", rec.Body.String())
}
rec = call(http.MethodPost, pushSubscriptionsPath, subscriptionJSON(t, testPushEndpoint, "Firefox 140"))
again := decode(rec)
if rec.Code != http.StatusOK || again["id"] != first["id"] || again["created_at"] != first["created_at"] || again["user_agent"] != "Firefox 140" {
t.Errorf("POST same endpoint = %d %s, want the first subscription updated", rec.Code, rec.Body.String())
}
if n := countPushSubscriptions(t, st); n != 1 {
t.Errorf("subscriptions after posting one endpoint twice = %d, want 1", n)
}
rec = call(http.MethodPost, pushSubscriptionsPath, subscriptionJSON(t, testPushEndpoint+"-phone", ""))
if body := decode(rec); rec.Code != http.StatusOK || body["user_agent"] != nil {
t.Errorf("POST second endpoint = %d %s", rec.Code, rec.Body.String())
}
if n := countPushSubscriptions(t, st); n != 2 {
t.Errorf("subscriptions after a second endpoint = %d, want 2", n)
}
for _, bad := range []struct{ body, code string }{
{"", "invalid_request"},
{`{"endpoint":"` + testPushEndpoint + `","keys":{"p256dh":"x","auth":"y"},"expirationTime":null}`, "invalid_request"},
{`{"endpoint":"` + testPushEndpoint + `","keys":{"p256dh":"x","auth":"y"},"extra":1}`, "invalid_request"},
{`{"endpoint":"` + testPushEndpoint + `"}`, "invalid_subscription"},
{`{"endpoint":"` + testPushEndpoint + `","keys":{"p256dh":"x","auth":"y"}}`, "invalid_subscription"},
{subscriptionJSON(t, "http://push.example.com/x", ""), "invalid_subscription"},
{`{"endpoint":"https://push.example.com/` + strings.Repeat("x", maxPushBody) + `"}`, "invalid_request"},
} {
rec := call(http.MethodPost, pushSubscriptionsPath, bad.body)
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `"code":"`+bad.code+`"`) {
t.Errorf("POST %.80s = %d %s, want 400 %s", bad.body, rec.Code, rec.Body.String(), bad.code)
}
}
if n := countPushSubscriptions(t, st); n != 2 {
t.Errorf("subscriptions after invalid posts = %d, want 2", n)
}
// DELETE removes by endpoint and is 204 whether or not it was stored.
for i := range 2 {
rec = call(http.MethodDelete, pushSubscriptionsPath, `{"endpoint":"`+testPushEndpoint+`"}`)
if rec.Code != http.StatusNoContent || rec.Body.Len() != 0 {
t.Errorf("DELETE #%d = %d %s, want 204", i+1, rec.Code, rec.Body.String())
}
}
if n := countPushSubscriptions(t, st); n != 1 {
t.Errorf("subscriptions after DELETE = %d, want 1", n)
}
for _, bad := range []string{"", `{}`, `{"endpoint":"not a url"}`} {
rec := call(http.MethodDelete, pushSubscriptionsPath, bad)
if rec.Code != http.StatusBadRequest {
t.Errorf("DELETE %q = %d %s, want 400", bad, rec.Code, rec.Body.String())
}
}
if n := countPushSubscriptions(t, st); n != 1 {
t.Errorf("subscriptions after invalid deletes = %d, want 1", n)
}
// The private key never leaves the process, in any encoding.
priv, err := base64.RawURLEncoding.DecodeString(pair.PrivateKey)
if err != nil {
t.Fatal(err)
}
leaks := []string{
pair.PrivateKey,
pair.PrivateKey[:16],
base64.StdEncoding.EncodeToString(priv),
base64.RawStdEncoding.EncodeToString(priv)[:16],
hex.EncodeToString(priv),
}
for _, b := range bodies {
for _, leak := range leaks {
if strings.Contains(b, leak) {
t.Errorf("response holds the VAPID private key: %s", b)
}
}
}
}
func TestPushSubscriptionsNeedCSRF(t *testing.T) {
t.Parallel()
svc, st, _ := newTestPushWith(t)
router := newPushRouter(t, svc)
if _, err := svc.Subscribe(t.Context(), push.SubscribeRequest{Endpoint: testPushEndpoint + "-stored", P256dh: mustP256dh(t), Auth: "AAAAAAAAAAAAAAAAAAAAAA"}); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
method, body string
}{
{http.MethodPost, subscriptionJSON(t, testPushEndpoint, "")},
{http.MethodDelete, `{"endpoint":"` + testPushEndpoint + `-stored"}`},
} {
for _, header := range []*string{nil, ptr(""), ptr("wrong-token")} {
req := httptest.NewRequest(tc.method, pushSubscriptionsPath, strings.NewReader(tc.body))
req.AddCookie(validSessionCookie)
if header != nil {
req.Header.Set(CSRFHeader, *header)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden || rec.Body.String() != csrfJSON {
t.Errorf("%s without a valid CSRF token (%v) = %d %s, want 403 csrf", tc.method, header, rec.Code, rec.Body.String())
}
}
}
if n := countPushSubscriptions(t, st); n != 1 {
t.Errorf("subscriptions after refused requests = %d, want the 1 stored before", n)
}
// GET needs no token.
req := httptest.NewRequest(http.MethodGet, vapidPublicKeyPath, nil)
req.AddCookie(validSessionCookie)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("GET public key without CSRF token = %d, want 200", rec.Code)
}
}
func TestPushNeedsSession(t *testing.T) {
t.Parallel()
router := newPushRouter(t, newTestPush(t))
for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodDelete} {
target := pushSubscriptionsPath
if method == http.MethodGet {
target = vapidPublicKeyPath
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(method, target, nil))
if rec.Code != http.StatusUnauthorized {
t.Errorf("%s %s without a session = %d, want 401", method, target, rec.Code)
}
}
}
func mustP256dh(t *testing.T) string {
t.Helper()
k, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
return base64.RawURLEncoding.EncodeToString(k.PublicKey().Bytes())
}
+1 -1
View File
@@ -22,7 +22,7 @@ const (
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t),
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t),
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+11 -2
View File
@@ -18,6 +18,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
@@ -55,12 +56,14 @@ type RouteMounter interface {
// /api/v1/shopping/items/{id}; googleSvc serves GET
// /api/v1/integrations/google and its /connect, /callback and /calendars,
// PUT /api/v1/integrations/google/calendar and DELETE
// /api/v1/integrations/google; runner serves
// /api/v1/integrations/google; pushSvc serves GET
// /api/v1/push/vapid-public-key and POST and DELETE
// /api/v1/push/subscriptions; runner serves
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
// GET /media/photos/{id}-{size}.jpg; assets is the web build
// served for every other GET. /health, /auth and the web build are public.
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, pushSvc *push.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
if settings == nil {
return nil, errors.New("server: nil household settings service")
}
@@ -85,6 +88,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
if googleSvc == nil {
return nil, errors.New("server: nil Google Calendar service")
}
if pushSvc == nil {
return nil, errors.New("server: nil push service")
}
if runner == nil {
return nil, errors.New("server: nil job runner")
}
@@ -158,6 +164,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
apiV1.Delete("/integrations/google", deleteGoogle(logger, googleSvc))
apiV1.Get("/integrations/google/calendars", getGoogleCalendars(logger, googleSvc))
apiV1.Put("/integrations/google/calendar", putGoogleCalendar(logger, googleSvc))
apiV1.Get("/push/vapid-public-key", getVAPIDPublicKey(logger, pushSvc))
apiV1.Post("/push/subscriptions", postPushSubscription(logger, pushSvc))
apiV1.Delete("/push/subscriptions", deletePushSubscription(logger, pushSvc))
apiV1.Post("/jobs", postJob(logger, runner))
apiV1.Get("/jobs/{id}", getJob(logger, runner))
media.Get("/photos/{name}", getPhoto(logger, photos))
+11 -11
View File
@@ -54,7 +54,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting
"index.html": {Data: []byte(fixtureIndex)},
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
}
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, newTestPhotos(t), assets)
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), runner, newTestPhotos(t), assets)
if err != nil {
t.Fatal(err)
}
@@ -287,7 +287,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil AI settings service")
}
}
@@ -296,7 +296,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil recipe search settings service")
}
}
@@ -305,7 +305,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted nil photo files")
}
}
@@ -314,7 +314,7 @@ func TestNewRouterRequiresSettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil settings service")
}
}
@@ -323,7 +323,7 @@ func TestNewRouterRequiresDinners(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil dinners service")
}
}
@@ -332,7 +332,7 @@ func TestNewRouterRequiresSuggestions(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil suggestions service")
}
}
@@ -341,7 +341,7 @@ func TestNewRouterRequiresPlan(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil plan service")
}
}
@@ -350,7 +350,7 @@ func TestNewRouterRequiresShopping(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil shopping service")
}
}
@@ -359,7 +359,7 @@ func TestNewRouterRequiresGoogle(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil Google Calendar service")
}
}
@@ -368,7 +368,7 @@ func TestNewRouterRequiresJobRunner(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil job runner")
}
}
+1 -1
View File
@@ -277,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -45,7 +45,7 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S
t.Fatal(err)
}
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+87
View File
@@ -0,0 +1,87 @@
// Package vapid generates and validates the VAPID key pair Web Push signs
// with (RFC 8292): a P-256 (ES256) key pair in the encoding webpush-go
// reads. Both keys are unpadded base64url: the public key is the 65-byte
// uncompressed point and the private key the 32-byte scalar.
package vapid
import (
"crypto/ecdh"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
)
// encoding is unpadded base64url. Strict refuses non-zero trailing bits, so
// every key has exactly one accepted spelling.
var encoding = base64.RawURLEncoding.Strict()
const (
publicKeySize = 65 // 0x04 || X || Y
privateKeySize = 32
)
// KeyPair is a VAPID key pair in its stored encoding.
type KeyPair struct {
PublicKey string
PrivateKey string
}
// Generate returns a new random key pair.
func Generate() (KeyPair, error) {
k, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
return KeyPair{}, fmt.Errorf("vapid: generate key: %w", err)
}
return KeyPair{
PublicKey: encoding.EncodeToString(k.PublicKey().Bytes()),
PrivateKey: encoding.EncodeToString(k.Bytes()),
}, nil
}
// ParsePublicKey parses an encoded public key, which must be a point on
// P-256.
func ParsePublicKey(s string) (*ecdh.PublicKey, error) {
b, err := encoding.DecodeString(s)
if err != nil {
return nil, errors.New("not unpadded base64url")
}
if len(b) != publicKeySize || b[0] != 0x04 {
return nil, fmt.Errorf("want the %d-byte uncompressed P-256 point, got %d bytes", publicKeySize, len(b))
}
k, err := ecdh.P256().NewPublicKey(b)
if err != nil {
return nil, errors.New("not a point on P-256")
}
return k, nil
}
// ParsePrivateKey parses an encoded private key. Its errors never quote the
// key.
func ParsePrivateKey(s string) (*ecdh.PrivateKey, error) {
b, err := encoding.DecodeString(s)
if err != nil {
return nil, errors.New("not unpadded base64url")
}
if len(b) != privateKeySize {
return nil, fmt.Errorf("want a %d-byte P-256 scalar, got %d bytes", privateKeySize, len(b))
}
k, err := ecdh.P256().NewPrivateKey(b)
if err != nil {
return nil, errors.New("not a valid P-256 scalar")
}
return k, nil
}
// ErrMismatch is returned by CheckPair when the private key's public key is
// not the given public key.
var ErrMismatch = errors.New("private key does not belong to the public key")
// CheckPair reports whether private is the private key of public, by
// deriving the public point from private and comparing.
func CheckPair(public *ecdh.PublicKey, private *ecdh.PrivateKey) error {
if !private.PublicKey().Equal(public) {
return ErrMismatch
}
return nil
}
+92
View File
@@ -0,0 +1,92 @@
package vapid
import (
"encoding/base64"
"strings"
"testing"
)
func TestGenerateRoundTrips(t *testing.T) {
t.Parallel()
a, err := Generate()
if err != nil {
t.Fatal(err)
}
b, err := Generate()
if err != nil {
t.Fatal(err)
}
if a == b {
t.Fatal("two generated key pairs are equal")
}
// 65 and 32 bytes in unpadded base64url.
if len(a.PublicKey) != 87 || len(a.PrivateKey) != 43 {
t.Errorf("key lengths = %d, %d; want 87, 43", len(a.PublicKey), len(a.PrivateKey))
}
pub, err := ParsePublicKey(a.PublicKey)
if err != nil {
t.Fatal(err)
}
priv, err := ParsePrivateKey(a.PrivateKey)
if err != nil {
t.Fatal(err)
}
if err := CheckPair(pub, priv); err != nil {
t.Errorf("CheckPair of a generated pair: %v", err)
}
other, err := ParsePrivateKey(b.PrivateKey)
if err != nil {
t.Fatal(err)
}
if err := CheckPair(pub, other); err != ErrMismatch {
t.Errorf("CheckPair of mismatched keys = %v, want ErrMismatch", err)
}
}
func TestParseRejectsMalformedKeys(t *testing.T) {
t.Parallel()
pair, err := Generate()
if err != nil {
t.Fatal(err)
}
raw := func(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
compressed, _ := base64.RawURLEncoding.DecodeString(pair.PublicKey)
compressed = append([]byte{0x02}, compressed[1:33]...)
offCurve := make([]byte, 65)
offCurve[0] = 0x04
offCurve[64] = 1
order := []byte{ // the P-256 group order n, not a valid scalar
0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xbc, 0xe6, 0xfa, 0xad, 0xa7, 0x17, 0x9e, 0x84, 0xf3, 0xb9, 0xca, 0xc2, 0xfc, 0x63, 0x25, 0x51,
}
for name, s := range map[string]string{
"empty": "",
"padded": pair.PublicKey + "=",
"standard alpha": strings.NewReplacer("-", "+", "_", "/").Replace(pair.PublicKey) + "+/",
"compressed": raw(compressed),
"off curve": raw(offCurve),
"private key": pair.PrivateKey,
} {
if _, err := ParsePublicKey(s); err == nil {
t.Errorf("ParsePublicKey(%s) succeeded", name)
}
}
for name, s := range map[string]string{
"empty": "",
"padded": pair.PrivateKey + "=",
"PEM": "-----BEGIN EC PRIVATE KEY-----",
"zero": raw(make([]byte, 32)),
"group size": raw(order),
"public key": pair.PublicKey,
} {
_, err := ParsePrivateKey(s)
if err == nil {
t.Errorf("ParsePrivateKey(%s) succeeded", name)
continue
}
if s != "" && strings.Contains(err.Error(), s) {
t.Errorf("ParsePrivateKey(%s) error quotes the key: %v", name, err)
}
}
}
+20
View File
@@ -0,0 +1,20 @@
-- name: UpsertPushSubscription :one
-- Stores a browser's push subscription, keyed by its endpoint. Subscribing
-- an endpoint already stored replaces its keys and user agent and clears
-- its failure count, keeping its id, created_at and last_success_at.
INSERT INTO push_subscriptions (
id, endpoint, p256dh, auth, user_agent, failure_count, created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, 0, ?, ?
)
ON CONFLICT (endpoint) DO UPDATE SET
p256dh = excluded.p256dh,
auth = excluded.auth,
user_agent = excluded.user_agent,
failure_count = 0,
updated_at = excluded.updated_at
RETURNING *;
-- name: DeletePushSubscription :execrows
DELETE FROM push_subscriptions
WHERE endpoint = ?;
+231
View File
@@ -0,0 +1,231 @@
import { describe, expect, it, vi } from 'vitest'
import {
APP_BADGE,
APP_ICON,
GENERIC_NOTICE,
appPath,
clickPath,
openNotification,
parsePushPayload,
planClick,
showPush,
type ClickWindow,
} from './notification.ts'
const origin = 'https://mealquest.example'
describe('appPath', () => {
it('keeps a same-origin path with its query and fragment', () => {
expect(appPath('/plan/shopping?week=2026-10-05#tuesday', origin)).toBe('/plan/shopping?week=2026-10-05#tuesday')
})
it.each([
'https://evil.example/plan',
'//evil.example/plan',
'/\\evil.example/plan',
'javascript:alert(1)',
'plan',
'',
])('refuses %s', (raw) => {
expect(appPath(raw, origin)).toBeNull()
})
it('resolves dot segments and keeps the result on origin', () => {
expect(appPath('/plan/../discover', origin)).toBe('/discover')
})
// Each of these starts with a single / but resolves to a path starting with
// //, which a browser reads as another host.
it.each([
'/..//evil.example/path',
'/plan/..//evil.example/path',
'/..\\\\evil.example/path',
'/./\\evil.example',
'/.\\/evil.example',
'/%2e%2e//evil.example/path',
'/.\t.//evil.example/path',
'/..\n//evil.example',
])('refuses %j, which resolves to a protocol-relative path', (raw) => {
expect(appPath(raw, origin)).toBeNull()
})
})
describe('parsePushPayload', () => {
it('reads title, body, tag and url', () => {
const text = JSON.stringify({ title: 'Plan ready', body: 'Your Draft board is ready.', tag: 'plan_ready', url: '/plan' })
expect(parsePushPayload(text, origin)).toEqual({
notice: { title: 'Plan ready', body: 'Your Draft board is ready.', tag: 'plan_ready', url: '/plan' },
problem: null,
})
})
it('opens home with an empty body and no tag when only a title is sent', () => {
expect(parsePushPayload(JSON.stringify({ title: 'Hi' }), origin)).toEqual({
notice: { title: 'Hi', body: '', url: '/' },
problem: null,
})
})
it('ignores fields it does not know', () => {
const parsed = parsePushPayload(JSON.stringify({ title: 'Hi', event: 'plan_ready' }), origin)
expect(parsed.problem).toBeNull()
expect(parsed.notice).toEqual({ title: 'Hi', body: '', url: '/' })
})
it.each([
[null, 'push message has no payload'],
['', 'push message has no payload'],
['\u0000ÿ not json', 'push payload is not JSON'],
['[1,2]', 'push payload is not a JSON object'],
['null', 'push payload is not a JSON object'],
['{"body":"no title"}', 'push payload has no title'],
['{"title":" "}', 'push payload has no title'],
['{"title":42}', 'push payload has no title'],
['{"title":"Hi","body":7}', 'push payload body is not a string'],
['{"title":"Hi","tag":""}', 'push payload tag is not a non-empty string'],
['{"title":"Hi","url":"https://evil.example/"}', 'push payload url is not a same-origin path'],
['{"title":"Hi","url":5}', 'push payload url is not a same-origin path'],
])('shows the generic notice for %j', (text, problem) => {
expect(parsePushPayload(text, origin)).toEqual({ notice: GENERIC_NOTICE, problem })
})
})
describe('showPush', () => {
it('shows the payload with the app icon and badge, its tag and its url as data', async () => {
const registration = { showNotification: vi.fn(async () => {}) }
const warn = vi.fn()
await showPush(
JSON.stringify({ title: 'Supply run ready', body: 'Time to shop.', tag: 'list_ready', url: '/plan/shopping' }),
registration,
origin,
warn,
)
expect(registration.showNotification).toHaveBeenCalledWith('Supply run ready', {
body: 'Time to shop.',
tag: 'list_ready',
icon: APP_ICON,
badge: APP_BADGE,
data: { url: '/plan/shopping' },
})
expect(warn).not.toHaveBeenCalled()
})
it('shows the generic notification for a malformed payload and logs why, without throwing', async () => {
const registration = { showNotification: vi.fn(async () => {}) }
const warn = vi.fn()
await expect(showPush('{not json', registration, origin, warn)).resolves.toBeUndefined()
expect(registration.showNotification).toHaveBeenCalledWith('MealQuest', {
body: GENERIC_NOTICE.body,
icon: APP_ICON,
badge: APP_BADGE,
data: { url: '/' },
})
expect(warn).toHaveBeenCalledWith('MealQuest: push payload is not JSON; showing the generic notification')
})
})
describe('clickPath', () => {
it('opens the url the notification carries', () => {
expect(clickPath({ url: '/discover' }, origin)).toBe('/discover')
})
it.each([undefined, null, 'x', {}, { url: 'https://evil.example/' }])('opens home for data %j', (data) => {
expect(clickPath(data, origin)).toBe('/')
})
})
describe('planClick', () => {
it('refuses a path that resolves off origin', () => {
expect(() => planClick([], '//evil.example/path', origin)).toThrow(/resolves off origin/)
})
const at = (path: string, focused = false) => ({ url: origin + path, focused })
it('opens a new window when no MealQuest window is open', () => {
expect(planClick([], '/plan', origin)).toEqual({ action: 'open', url: `${origin}/plan` })
})
it('ignores windows on another origin', () => {
const other = { url: 'https://other.example/plan', focused: true }
expect(planClick([other], '/plan', origin)).toEqual({ action: 'open', url: `${origin}/plan` })
})
it('focuses the focused MealQuest window and navigates it', () => {
const first = at('/')
const focused = at('/discover', true)
expect(planClick([first, focused], '/plan', origin)).toEqual({
action: 'focus',
window: focused,
url: `${origin}/plan`,
navigate: true,
})
})
it('focuses the first MealQuest window when none is focused', () => {
const first = at('/')
expect(planClick([first, at('/discover')], '/plan', origin)).toMatchObject({ action: 'focus', window: first })
})
it('does not reload a window already at the url', () => {
expect(planClick([at('/plan')], '/plan', origin)).toMatchObject({ action: 'focus', navigate: false })
})
})
describe('openNotification', () => {
function window(path: string, focused = false) {
return {
url: origin + path,
focused,
focus: vi.fn(async () => {}),
navigate: vi.fn(async () => {}),
} satisfies ClickWindow
}
function clients(windows: ClickWindow[]) {
return { matchAll: vi.fn(async () => windows), openWindow: vi.fn(async () => null) }
}
it('focuses an open window, then navigates it to the url', async () => {
const win = window('/', true)
const all = clients([win])
await openNotification({ url: '/plan/shopping' }, all, origin)
expect(all.matchAll).toHaveBeenCalledWith({ type: 'window' })
expect(win.focus).toHaveBeenCalled()
expect(win.navigate).toHaveBeenCalledWith(`${origin}/plan/shopping`)
expect(win.focus.mock.invocationCallOrder[0]).toBeLessThan(win.navigate.mock.invocationCallOrder[0]!)
expect(all.openWindow).not.toHaveBeenCalled()
})
it('opens a new window at the url when none is open', async () => {
const all = clients([])
await openNotification({ url: '/discover' }, all, origin)
expect(all.openWindow).toHaveBeenCalledWith(`${origin}/discover`)
})
it('never opens a url off origin, even from tampered data', async () => {
const all = clients([])
await openNotification({ url: 'https://evil.example/' }, all, origin)
expect(all.openWindow).toHaveBeenCalledWith(`${origin}/`)
})
it.each(['/..//evil.example/path', '/..\\\\evil.example/path', '/%2e%2e//evil.example/path', '/.\t.//evil.example/path'])(
'never leaves origin for %j, which resolves to a protocol-relative path',
async (url) => {
const win = window('/plan', true)
const all = clients([win])
await openNotification({ url }, all, origin)
expect(win.navigate).toHaveBeenCalledWith(`${origin}/`)
expect(all.openWindow).not.toHaveBeenCalled()
const none = clients([])
await openNotification({ url }, none, origin)
expect(none.openWindow).toHaveBeenCalledWith(`${origin}/`)
},
)
it('still opens an internal path that uses dot segments', async () => {
const all = clients([])
await openNotification({ url: '/plan/../discover' }, all, origin)
expect(all.openWindow).toHaveBeenCalledWith(`${origin}/discover`)
})
})
+174
View File
@@ -0,0 +1,174 @@
// Web Push notifications as the service worker shows them, and where a tap on
// one takes the household. The worker's push and notificationclick listeners
// (src/sw.ts) only adapt these to the worker's globals, so every decision runs
// under test without a real ServiceWorkerGlobalScope. This file is checked
// with both the DOM and the WebWorker libraries, so it names only the narrow
// shapes it uses.
// APP_ICON and APP_BADGE are the app icon from the web manifest.
export const APP_ICON = '/icons/icon-192.png'
export const APP_BADGE = '/icons/icon-192.png'
// PushNotice is a notification to show: its title, body and tag, and the
// same-origin path a tap opens.
export type PushNotice = { title: string; body: string; tag?: string; url: string }
// GENERIC_NOTICE is shown for a push whose payload is not a notice: the
// household still learns that MealQuest has something for them.
export const GENERIC_NOTICE: PushNotice = {
title: 'MealQuest',
body: "There's something new in MealQuest.",
url: '/',
}
// ParsedPush is the notice to show for a push, and why its payload was not
// used, when it was not.
export type ParsedPush = { notice: PushNotice; problem: string | null }
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
// appPath returns raw as a path on origin (path, query and fragment), or null
// when raw is not a path-absolute URL that stays on origin. A payload never
// sends the household to another site. The path is checked after the parser
// has resolved it: dot segments (also as %2e), backslashes and stripped tabs
// and newlines can all turn an innocent-looking path into one starting with
// //, which a browser would read as another host.
export function appPath(raw: string, origin: string): string | null {
if (!raw.startsWith('/') || raw.startsWith('//')) return null
let url: URL
try {
url = new URL(raw, origin)
} catch {
return null
}
if (url.origin !== origin || url.pathname.startsWith('//')) return null
return url.pathname + url.search + url.hash
}
// parsePushPayload reads a push message's payload, which may be any bytes:
// a JSON object with a non-empty string title, and optionally a string body,
// a non-empty string tag and a url that is a same-origin path. Other fields
// are ignored. Anything else — no payload, not JSON, a missing title, a
// field of the wrong type, a url off origin — is shown as GENERIC_NOTICE,
// with the problem for the worker to log. It never throws.
export function parsePushPayload(text: string | null, origin: string): ParsedPush {
const generic = (problem: string): ParsedPush => ({ notice: GENERIC_NOTICE, problem })
if (text === null || text === '') return generic('push message has no payload')
let payload: unknown
try {
payload = JSON.parse(text)
} catch {
return generic('push payload is not JSON')
}
if (!isRecord(payload)) return generic('push payload is not a JSON object')
const { title, body, tag, url } = payload
if (typeof title !== 'string' || title.trim() === '') return generic('push payload has no title')
if (body !== undefined && typeof body !== 'string') return generic('push payload body is not a string')
if (tag !== undefined && (typeof tag !== 'string' || tag === '')) {
return generic('push payload tag is not a non-empty string')
}
let path = '/'
if (url !== undefined) {
const safe = typeof url === 'string' ? appPath(url, origin) : null
if (safe === null) return generic('push payload url is not a same-origin path')
path = safe
}
const notice: PushNotice = { title, body: body ?? '', url: path }
if (tag !== undefined) notice.tag = tag
return { notice, problem: null }
}
// NoticeData is the data a shown notification carries for its click.
export type NoticeData = { url: string }
// notificationOptions is what showNotification is given for notice: its body
// and tag, the app icon and badge, and the path to open as its data.
export function notificationOptions(notice: PushNotice): NotificationOptions & { data: NoticeData } {
const options: NotificationOptions & { data: NoticeData } = {
body: notice.body,
icon: APP_ICON,
badge: APP_BADGE,
data: { url: notice.url },
}
if (notice.tag !== undefined) options.tag = notice.tag
return options
}
export type NotificationShower = {
showNotification(title: string, options: NotificationOptions): Promise<void>
}
// showPush shows the notification for a push message's payload text,
// reporting through warn why a payload was not used.
export function showPush(
text: string | null,
registration: NotificationShower,
origin: string,
warn: (message: string) => void,
): Promise<void> {
const { notice, problem } = parsePushPayload(text, origin)
if (problem !== null) warn(`MealQuest: ${problem}; showing the generic notification`)
return registration.showNotification(notice.title, notificationOptions(notice))
}
// clickPath is the same-origin path a tapped notification opens: the url in
// its data, which showPush put there, or the app's home when the data is not
// ours (a notification shown by an older worker).
export function clickPath(data: unknown, origin: string): string {
if (isRecord(data) && typeof data.url === 'string') {
const path = appPath(data.url, origin)
if (path !== null) return path
}
return '/'
}
// AppWindow is the part of a window client a click reads.
export type AppWindow = { url: string; focused: boolean }
// ClickPlan is what a notification tap does: focus an open MealQuest window
// and navigate it to url, or open a new window at url. url is absolute.
export type ClickPlan<W> = { action: 'focus'; window: W; url: string; navigate: boolean } | { action: 'open'; url: string }
// planClick decides a tap on a notification that opens path: the focused
// MealQuest window if there is one, else the first open one, navigated to
// path unless it is already there; with none open, a new window. A path that
// resolves off origin is an error: a tap never leaves MealQuest.
export function planClick<W extends AppWindow>(windows: readonly W[], path: string, origin: string): ClickPlan<W> {
const resolved = new URL(path, origin)
if (resolved.origin !== origin) {
throw new Error(`MealQuest: notification path ${JSON.stringify(path)} resolves off origin to ${resolved.origin}`)
}
const url = resolved.href
const ours = windows.filter((w) => URL.canParse(w.url) && new URL(w.url).origin === origin)
const target = ours.find((w) => w.focused) ?? ours[0]
if (target === undefined) return { action: 'open', url }
return { action: 'focus', window: target, url, navigate: target.url !== url }
}
export type ClickWindow = AppWindow & {
focus(): Promise<unknown>
navigate(url: string): Promise<unknown>
}
export type ClickClients<W extends ClickWindow> = {
matchAll(options: { type: 'window' }): Promise<readonly W[]>
openWindow(url: string): Promise<unknown>
}
// openNotification carries out a tap on a notification with data: focus
// first, while the tap still allows it, then navigate.
export async function openNotification<W extends ClickWindow>(
data: unknown,
clients: ClickClients<W>,
origin: string,
): Promise<void> {
const plan = planClick(await clients.matchAll({ type: 'window' }), clickPath(data, origin), origin)
if (plan.action === 'open') {
await clients.openWindow(plan.url)
return
}
await plan.window.focus()
if (plan.navigate) await plan.window.navigate(plan.url)
}
+308
View File
@@ -0,0 +1,308 @@
import { describe, expect, it, vi } from 'vitest'
import { ApiError, type ApiRequest } from '../api/index.ts'
import {
SUBSCRIPTIONS_PATH,
VAPID_PUBLIC_KEY_PATH,
decodeVapidKey,
encodeBase64url,
PermissionGestureError,
subscribePush,
subscriptionBody,
unsubscribePush,
type BrowserPushSubscription,
type PushEnvironment,
} from './subscribe.ts'
function bytes(length: number, first: number): Uint8Array<ArrayBuffer> {
const out = new Uint8Array(new ArrayBuffer(length))
for (let i = 0; i < length; i++) out[i] = (first + i) % 256
return out
}
const vapidKey = bytes(65, 4)
const vapidKeyText = encodeBase64url(vapidKey.buffer)
const p256dh = bytes(65, 4).buffer
const auth = bytes(16, 200).buffer
const endpoint = 'https://push.example/send/abc'
function subscription(key: ArrayBuffer | null = vapidKey.buffer, at = endpoint) {
return {
endpoint: at,
options: { applicationServerKey: key },
getKey: (name: 'p256dh' | 'auth') => (name === 'p256dh' ? p256dh : auth),
unsubscribe: vi.fn(async () => true),
} satisfies BrowserPushSubscription
}
type Setup = {
permission?: NotificationPermission
answer?: NotificationPermission
active?: boolean
current?: BrowserPushSubscription | null
registered?: boolean
subscribeError?: unknown
}
function setup({
permission = 'default',
answer = 'granted',
active = true,
current = null,
registered = true,
subscribeError,
}: Setup = {}) {
const created = subscription()
const pushManager = {
getSubscription: vi.fn(async () => current),
subscribe: vi.fn(async () => {
if (subscribeError !== undefined) throw subscribeError
return created
}),
}
const notification = { permission, requestPermission: vi.fn(async () => answer) }
const env: PushEnvironment = {
serviceWorker: { getRegistration: vi.fn(async () => (registered ? { pushManager } : undefined)) },
pushManager: true,
notification,
userActivation: { isActive: active },
userAgent: 'Mozilla/5.0 Test',
}
const calls: { path: string; request?: ApiRequest }[] = []
const api = {
apiFetch: vi.fn(async (path: string, request?: ApiRequest) => {
calls.push({ path, request })
if (path === VAPID_PUBLIC_KEY_PATH) return { public_key: vapidKeyText }
return undefined
}) as <T>(path: string, request?: ApiRequest) => Promise<T>,
}
return { env, api, calls, pushManager, notification, created }
}
describe('decodeVapidKey', () => {
it('decodes the unpadded base64url point', () => {
expect(decodeVapidKey(vapidKeyText)).toEqual(vapidKey)
})
it.each(['', 'abc=', 'not base64!', encodeBase64url(bytes(64, 4).buffer), encodeBase64url(bytes(65, 2).buffer)])(
'refuses %j',
(key) => {
expect(() => decodeVapidKey(key)).toThrow(/65-byte uncompressed P-256 point/)
},
)
})
describe('subscriptionBody', () => {
it('sends exactly the endpoint, the keys in unpadded base64url and the user agent', () => {
const body = subscriptionBody(subscription(), 'Mozilla/5.0 Test')
expect(body).toEqual({
endpoint,
keys: { p256dh: encodeBase64url(p256dh), auth: encodeBase64url(auth) },
user_agent: 'Mozilla/5.0 Test',
})
expect(body.keys.auth).toMatch(/^[A-Za-z0-9_-]{22}$/)
expect(JSON.stringify(body)).not.toContain('expirationTime')
})
it.each(['', ' ', 'x'.repeat(513)])('leaves out a blank or too-long user agent', (userAgent) => {
expect(subscriptionBody(subscription(), userAgent)).not.toHaveProperty('user_agent')
})
it('refuses a subscription without keys', () => {
const keyless = { ...subscription(), getKey: () => null }
expect(() => subscriptionBody(keyless, 'ua')).toThrow(/without its p256dh and auth keys/)
})
})
describe('subscribePush', () => {
it.each([
['serviceWorker', { serviceWorker: undefined }],
['PushManager', { pushManager: false }],
['Notification', { notification: undefined }],
])('answers unsupported without %s, asking nothing', async (_, missing) => {
const { env, api, notification } = setup()
expect(await subscribePush(api, { ...env, ...missing })).toBe('unsupported')
expect(notification.requestPermission).not.toHaveBeenCalled()
expect(api.apiFetch).not.toHaveBeenCalled()
})
it('asks for permission during a tap, subscribes with the server key and stores the subscription', async () => {
const { env, api, calls, pushManager, notification } = setup()
expect(await subscribePush(api, env)).toBe('subscribed')
expect(notification.requestPermission).toHaveBeenCalledTimes(1)
expect(pushManager.subscribe).toHaveBeenCalledWith({ userVisibleOnly: true, applicationServerKey: vapidKey })
expect(calls).toEqual([
{ path: VAPID_PUBLIC_KEY_PATH, request: undefined },
{
path: SUBSCRIPTIONS_PATH,
request: {
method: 'POST',
body: {
endpoint,
keys: { p256dh: encodeBase64url(p256dh), auth: encodeBase64url(auth) },
user_agent: 'Mozilla/5.0 Test',
},
},
},
])
})
it('asks for permission before awaiting anything, so the tap still counts', async () => {
const { env, api, notification } = setup()
const pending = subscribePush(api, env)
expect(notification.requestPermission).toHaveBeenCalledTimes(1)
expect(api.apiFetch).not.toHaveBeenCalled()
await pending
})
it('refuses to ask for permission outside a tap', async () => {
const { env, api, notification } = setup({ active: false })
const error = await subscribePush(api, env).catch((e: unknown) => e)
expect(error).toBeInstanceOf(PermissionGestureError)
expect(error).toMatchObject({ reason: 'inactive', message: expect.stringMatching(/only be asked for from a tap/) })
expect(notification.requestPermission).not.toHaveBeenCalled()
expect(api.apiFetch).not.toHaveBeenCalled()
})
it('refuses to ask for permission where the browser cannot say whether a tap is active', async () => {
const { env, api, notification } = setup()
const error = await subscribePush(api, { ...env, userActivation: undefined }).catch((e: unknown) => e)
expect(error).toBeInstanceOf(PermissionGestureError)
expect(error).toMatchObject({ reason: 'unobservable', message: expect.stringMatching(/no navigator.userActivation/) })
expect(notification.requestPermission).not.toHaveBeenCalled()
expect(api.apiFetch).not.toHaveBeenCalled()
})
it('does not need to observe a tap once permission is granted', async () => {
const { env, api, notification } = setup({ permission: 'granted' })
expect(await subscribePush(api, { ...env, userActivation: undefined })).toBe('subscribed')
expect(notification.requestPermission).not.toHaveBeenCalled()
})
it('does not ask again once permission is granted, even outside a tap', async () => {
const { env, api, notification } = setup({ permission: 'granted', active: false })
expect(await subscribePush(api, env)).toBe('subscribed')
expect(notification.requestPermission).not.toHaveBeenCalled()
})
it('reports a permission denied earlier without asking or subscribing', async () => {
const { env, api, notification, pushManager } = setup({ permission: 'denied' })
expect(await subscribePush(api, env)).toBe('denied')
expect(notification.requestPermission).not.toHaveBeenCalled()
expect(pushManager.subscribe).not.toHaveBeenCalled()
expect(api.apiFetch).not.toHaveBeenCalled()
})
it('reports a permission denied when asked, without subscribing', async () => {
const { env, api, pushManager } = setup({ answer: 'denied' })
expect(await subscribePush(api, env)).toBe('denied')
expect(pushManager.subscribe).not.toHaveBeenCalled()
expect(api.apiFetch).not.toHaveBeenCalled()
})
it('reports a dismissed prompt', async () => {
const { env, api, pushManager } = setup({ answer: 'default' })
expect(await subscribePush(api, env)).toBe('dismissed')
expect(pushManager.subscribe).not.toHaveBeenCalled()
})
it('reports denied when the browser refuses the subscription for permission', async () => {
const { env, api, calls } = setup({ subscribeError: new DOMException('Registration failed - permission denied', 'NotAllowedError') })
expect(await subscribePush(api, env)).toBe('denied')
expect(calls.map((c) => c.path)).toEqual([VAPID_PUBLIC_KEY_PATH])
})
it('rejects with an Error, not a raw DOMException, when the browser cannot subscribe', async () => {
const cause = new DOMException('push service not available', 'AbortError')
const { env, api } = setup({ subscribeError: cause })
const error = await subscribePush(api, env).catch((e: unknown) => e)
expect(error).not.toBeInstanceOf(DOMException)
expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toBe('Web Push: the browser could not subscribe: push service not available')
expect((error as Error).cause).toBe(cause)
})
it('rejects when no service worker is registered', async () => {
const { env, api } = setup({ registered: false })
await expect(subscribePush(api, env)).rejects.toThrow(/no service worker is registered/)
})
it('stores an existing subscription made with the same key again, without resubscribing', async () => {
const current = subscription()
const { env, api, calls, pushManager } = setup({ permission: 'granted', current })
expect(await subscribePush(api, env)).toBe('subscribed')
expect(pushManager.subscribe).not.toHaveBeenCalled()
expect(current.unsubscribe).not.toHaveBeenCalled()
expect(calls.map((c) => c.request?.method ?? 'GET')).toEqual(['GET', 'POST'])
})
it('replaces a subscription made with an older key', async () => {
const old = subscription(bytes(65, 9).buffer, 'https://push.example/send/old')
const { env, api, calls, pushManager } = setup({ permission: 'granted', current: old })
expect(await subscribePush(api, env)).toBe('subscribed')
expect(old.unsubscribe).toHaveBeenCalled()
expect(pushManager.subscribe).toHaveBeenCalled()
expect(calls.map((c) => [c.request?.method ?? 'GET', c.request?.body])).toEqual([
['GET', undefined],
['DELETE', { endpoint: 'https://push.example/send/old' }],
['POST', expect.objectContaining({ endpoint })],
])
})
it('passes on an API failure storing the subscription', async () => {
const { env, api } = setup()
const refused = new ApiError(400, 'invalid_subscription', 'endpoint: want an absolute https:// URL', false)
vi.mocked(api.apiFetch).mockImplementation(async (path: string) => {
if (path === VAPID_PUBLIC_KEY_PATH) return { public_key: vapidKeyText } as never
throw refused
})
await expect(subscribePush(api, env)).rejects.toBe(refused)
})
it('rejects a VAPID key response without a public_key', async () => {
const { env, api } = setup()
vi.mocked(api.apiFetch).mockResolvedValue({} as never)
await expect(subscribePush(api, env)).rejects.toThrow(`GET ${VAPID_PUBLIC_KEY_PATH}: response has no public_key`)
})
})
describe('unsubscribePush', () => {
it('deletes the subscription on the server, then in the browser', async () => {
const current = subscription()
const { env, api, calls } = setup({ current })
expect(await unsubscribePush(api, env)).toBe('unsubscribed')
expect(calls).toEqual([{ path: SUBSCRIPTIONS_PATH, request: { method: 'DELETE', body: { endpoint } } }])
expect(current.unsubscribe).toHaveBeenCalled()
})
it('answers not-subscribed without calling the server when there is no subscription', async () => {
const { env, api } = setup({ current: null })
expect(await unsubscribePush(api, env)).toBe('not-subscribed')
expect(api.apiFetch).not.toHaveBeenCalled()
})
it('answers not-subscribed when no service worker is registered', async () => {
const { env, api } = setup({ registered: false })
expect(await unsubscribePush(api, env)).toBe('not-subscribed')
expect(api.apiFetch).not.toHaveBeenCalled()
})
it('answers unsupported without Web Push', async () => {
const { env, api } = setup()
expect(await unsubscribePush(api, { ...env, pushManager: false })).toBe('unsupported')
})
it('keeps the browser subscribed when the server delete fails, so trying again works', async () => {
const current = subscription()
const { env, api } = setup({ current })
vi.mocked(api.apiFetch).mockRejectedValue(new TypeError('Failed to fetch'))
await expect(unsubscribePush(api, env)).rejects.toThrow('Failed to fetch')
expect(current.unsubscribe).not.toHaveBeenCalled()
})
it('rejects when the browser does not remove the subscription', async () => {
const current = subscription()
current.unsubscribe.mockResolvedValue(false)
const { env, api } = setup({ current })
await expect(unsubscribePush(api, env)).rejects.toThrow(/did not remove its push subscription/)
})
})
+209
View File
@@ -0,0 +1,209 @@
import type { ApiClient } from '../api/client.ts'
import { isRecord } from '../api/errors.ts'
export const VAPID_PUBLIC_KEY_PATH = '/api/v1/push/vapid-public-key'
export const SUBSCRIPTIONS_PATH = '/api/v1/push/subscriptions'
// MAX_USER_AGENT_BYTES is the server's limit on a subscription's user_agent
// (internal/push MaxUserAgentBytes).
export const MAX_USER_AGENT_BYTES = 512
// SubscribePushResult is how turning on Web Push on this device ended:
// subscribed, the browser's permission denied (now or earlier), the
// permission prompt dismissed without an answer, or a browser without Web
// Push. Any other failure rejects with an Error (an ApiError from the API).
export type SubscribePushResult = 'subscribed' | 'denied' | 'dismissed' | 'unsupported'
// UnsubscribePushResult is how turning off Web Push on this device ended:
// its subscription removed from the browser and the server, no subscription
// to remove, or a browser without Web Push. Any other failure rejects.
export type UnsubscribePushResult = 'unsubscribed' | 'not-subscribed' | 'unsupported'
// The parts of the browser's Push API this module uses.
export type BrowserPushSubscription = {
readonly endpoint: string
readonly options: { readonly applicationServerKey: ArrayBuffer | null }
getKey(name: 'p256dh' | 'auth'): ArrayBuffer | null
unsubscribe(): Promise<boolean>
}
export type BrowserPushManager = {
getSubscription(): Promise<BrowserPushSubscription | null>
subscribe(options: { userVisibleOnly: boolean; applicationServerKey: Uint8Array<ArrayBuffer> }): Promise<BrowserPushSubscription>
}
// PushEnvironment is what the browser offers for Web Push. A field is
// undefined (or pushManager false) where the browser lacks it.
export type PushEnvironment = {
serviceWorker: { getRegistration(): Promise<{ readonly pushManager: BrowserPushManager } | undefined> } | undefined
pushManager: boolean
notification:
| { readonly permission: NotificationPermission; requestPermission(): Promise<NotificationPermission> }
| undefined
// Whether the page is handling a user gesture right now.
userActivation: { readonly isActive: boolean } | undefined
userAgent: string
}
// browserPushEnvironment reads the PushEnvironment from this window.
export function browserPushEnvironment(): PushEnvironment {
return {
serviceWorker: 'serviceWorker' in navigator ? navigator.serviceWorker : undefined,
pushManager: 'PushManager' in window,
notification: 'Notification' in window ? Notification : undefined,
userActivation: 'userActivation' in navigator ? navigator.userActivation : undefined,
userAgent: navigator.userAgent,
}
}
type PushApi = Pick<ApiClient, 'apiFetch'>
// PermissionGestureError is why subscribePush refused to ask for notification
// permission: no tap is active right now ('inactive'), or the browser cannot
// say whether one is ('unobservable', no navigator.userActivation).
export class PermissionGestureError extends Error {
readonly reason: 'inactive' | 'unobservable'
constructor(reason: 'inactive' | 'unobservable') {
super(
reason === 'inactive'
? 'subscribePush: notification permission can only be asked for from a tap on a button'
: 'subscribePush: this browser cannot report whether a tap is active (no navigator.userActivation), so notification permission is not asked for',
)
this.name = 'PermissionGestureError'
this.reason = reason
}
}
// encodeBase64url encodes bytes as unpadded base64url, as the server wants
// subscription keys.
export function encodeBase64url(bytes: ArrayBuffer): string {
let binary = ''
for (const byte of new Uint8Array(bytes)) binary += String.fromCharCode(byte)
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '')
}
// decodeVapidKey decodes the server's VAPID public key, unpadded base64url
// of the 65-byte uncompressed P-256 point, into an applicationServerKey.
export function decodeVapidKey(key: string): Uint8Array<ArrayBuffer> {
const problem = `GET ${VAPID_PUBLIC_KEY_PATH}: public_key is not the unpadded base64url of a 65-byte uncompressed P-256 point`
if (!/^[A-Za-z0-9_-]+$/.test(key) || key.length % 4 === 1) throw new Error(problem)
const binary = atob(key.replaceAll('-', '+').replaceAll('_', '/'))
const bytes = new Uint8Array(new ArrayBuffer(binary.length))
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
if (bytes.length !== 65 || bytes[0] !== 0x04) throw new Error(problem)
return bytes
}
function sameKey(current: ArrayBuffer | null, key: Uint8Array): boolean {
if (current === null || current.byteLength !== key.length) return false
const bytes = new Uint8Array(current)
return bytes.every((byte, i) => byte === key[i])
}
// SubscriptionBody is the POST /api/v1/push/subscriptions body. The server
// refuses unknown fields and nulls, so it is built field by field rather
// than from PushSubscription.toJSON(), whose expirationTime may be null.
export type SubscriptionBody = { endpoint: string; keys: { p256dh: string; auth: string }; user_agent?: string }
// subscriptionBody is the body that stores subscription. The user agent only
// labels the device, so one that is blank or over the server's limit is left
// out rather than sent to be refused.
export function subscriptionBody(subscription: BrowserPushSubscription, userAgent: string): SubscriptionBody {
const p256dh = subscription.getKey('p256dh')
const auth = subscription.getKey('auth')
if (p256dh === null || auth === null) {
throw new Error('Web Push: the browser gave a subscription without its p256dh and auth keys')
}
const body: SubscriptionBody = {
endpoint: subscription.endpoint,
keys: { p256dh: encodeBase64url(p256dh), auth: encodeBase64url(auth) },
}
if (userAgent.trim() !== '' && new TextEncoder().encode(userAgent).length <= MAX_USER_AGENT_BYTES) {
body.user_agent = userAgent
}
return body
}
async function fetchVapidKey(api: PushApi): Promise<Uint8Array<ArrayBuffer>> {
const body = await api.apiFetch<unknown>(VAPID_PUBLIC_KEY_PATH)
if (!isRecord(body) || typeof body.public_key !== 'string') {
throw new Error(`GET ${VAPID_PUBLIC_KEY_PATH}: response has no public_key`)
}
return decodeVapidKey(body.public_key)
}
// removeSubscription deletes subscription on the server, then in the
// browser. Deleting on the server first means a failure leaves both still
// subscribed, so trying again finds the subscription and finishes the job.
async function removeSubscription(api: PushApi, subscription: BrowserPushSubscription): Promise<void> {
await api.apiFetch<void>(SUBSCRIPTIONS_PATH, { method: 'DELETE', body: { endpoint: subscription.endpoint } })
if (!(await subscription.unsubscribe())) {
throw new Error('Web Push: the browser did not remove its push subscription')
}
}
function errorName(error: unknown): string | undefined {
return isRecord(error) && typeof error.name === 'string' ? error.name : undefined
}
// subscribePush turns on Web Push on this device. Call it only from the
// household's tap on a button: when the browser has not yet been asked, it
// asks for notification permission, which browsers allow only during a user
// gesture, and it refuses to ask outside one — or where the browser cannot
// say whether a gesture is active — with a PermissionGestureError. Permission
// is asked before
// anything is awaited so the gesture still counts. With permission, it
// subscribes the browser with the server's VAPID public key — replacing a
// subscription made with an older key — and stores the subscription on the
// server. Subscribing again when already subscribed stores it again.
export async function subscribePush(api: PushApi, env: PushEnvironment): Promise<SubscribePushResult> {
const { serviceWorker, notification } = env
if (serviceWorker === undefined || !env.pushManager || notification === undefined) return 'unsupported'
let permission = notification.permission
if (permission === 'default') {
if (env.userActivation === undefined) throw new PermissionGestureError('unobservable')
if (!env.userActivation.isActive) throw new PermissionGestureError('inactive')
permission = await notification.requestPermission()
}
if (permission === 'denied') return 'denied'
if (permission === 'default') return 'dismissed'
const found = await serviceWorker.getRegistration()
if (found === undefined) {
throw new Error('Web Push: no service worker is registered for this page, so it cannot receive notifications')
}
const { pushManager } = found
const key = await fetchVapidKey(api)
let subscription = await pushManager.getSubscription()
if (subscription !== null && !sameKey(subscription.options.applicationServerKey, key)) {
await removeSubscription(api, subscription)
subscription = null
}
if (subscription === null) {
try {
subscription = await pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key })
} catch (error) {
if (errorName(error) === 'NotAllowedError') return 'denied'
throw new Error(`Web Push: the browser could not subscribe: ${(error as Error).message}`, { cause: error })
}
}
await api.apiFetch(SUBSCRIPTIONS_PATH, { method: 'POST', body: subscriptionBody(subscription, env.userAgent) })
return 'subscribed'
}
// unsubscribePush turns off Web Push on this device: it removes the
// browser's subscription on the server and in the browser. With no
// subscription there is nothing to remove, and it answers 'not-subscribed'
// without calling the server.
export async function unsubscribePush(api: PushApi, env: PushEnvironment): Promise<UnsubscribePushResult> {
const { serviceWorker } = env
if (serviceWorker === undefined || !env.pushManager) return 'unsupported'
const found = await serviceWorker.getRegistration()
if (found === undefined) return 'not-subscribed'
const subscription = await found.pushManager.getSubscription()
if (subscription === null) return 'not-subscribed'
await removeSubscription(api, subscription)
return 'unsubscribed'
}
+29
View File
@@ -0,0 +1,29 @@
import { useMemo } from 'react'
import { useApi } from '../api/ApiContext.tsx'
import {
browserPushEnvironment,
subscribePush,
unsubscribePush,
type SubscribePushResult,
type UnsubscribePushResult,
} from './subscribe.ts'
export type Push = {
// subscribePush turns on Web Push on this device; call it only from a tap.
subscribePush(): Promise<SubscribePushResult>
// unsubscribePush turns off Web Push on this device.
unsubscribePush(): Promise<UnsubscribePushResult>
}
// usePush binds subscribePush and unsubscribePush to the app's API client
// and this browser.
export function usePush(): Push {
const api = useApi()
return useMemo(
() => ({
subscribePush: () => subscribePush(api, browserPushEnvironment()),
unsubscribePush: () => unsubscribePush(api, browserPushEnvironment()),
}),
[api],
)
}
+54
View File
@@ -50,3 +50,57 @@ describe('service worker activation', () => {
expect(cacheStorage.delete).toHaveBeenCalledTimes(2)
})
})
// The push listeners only hand src/push/notification.ts the worker's globals;
// jsdom has no ServiceWorkerGlobalScope, so registration and clients are
// stubbed and the events are plain Events with the worker event's fields.
describe('service worker push listeners', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
async function dispatch(type: string, fields: object): Promise<void> {
vi.stubGlobal('caches', { keys: vi.fn(async () => []), delete: vi.fn() })
vi.stubGlobal('__WB_MANIFEST', [])
await import('./sw')
const pending: Promise<unknown>[] = []
self.dispatchEvent(
Object.assign(new Event(type), { waitUntil: (promise: Promise<unknown>) => pending.push(promise) }, fields),
)
expect(pending).toHaveLength(1)
await Promise.all(pending)
}
it('shows a push message as a notification', async () => {
const registration = { showNotification: vi.fn(async () => {}) }
vi.stubGlobal('registration', registration)
const payload = JSON.stringify({ title: 'Plan ready', body: 'Adjust the Draft board.', tag: 'plan_ready', url: '/plan' })
await dispatch('push', { data: { text: () => payload } })
expect(registration.showNotification).toHaveBeenCalledWith('Plan ready', {
body: 'Adjust the Draft board.',
tag: 'plan_ready',
icon: '/icons/icon-192.png',
badge: '/icons/icon-192.png',
data: { url: '/plan' },
})
})
it('shows the generic notification for a push without a payload', async () => {
const registration = { showNotification: vi.fn(async () => {}) }
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.stubGlobal('registration', registration)
await dispatch('push', { data: null })
expect(registration.showNotification).toHaveBeenCalledWith('MealQuest', expect.objectContaining({ data: { url: '/' } }))
expect(warn).toHaveBeenCalledWith('MealQuest: push message has no payload; showing the generic notification')
warn.mockRestore()
})
it('closes a tapped notification and opens its url', async () => {
const clients = { matchAll: vi.fn(async () => []), openWindow: vi.fn(async () => null) }
vi.stubGlobal('clients', clients)
const notification = { data: { url: '/discover' }, close: vi.fn() }
await dispatch('notificationclick', { notification })
expect(notification.close).toHaveBeenCalled()
expect(clients.openWindow).toHaveBeenCalledWith(`${self.location.origin}/discover`)
})
})
+16
View File
@@ -15,6 +15,7 @@ import { ExpirationPlugin } from 'workbox-expiration'
import { PrecacheFallbackPlugin, matchPrecache, precacheAndRoute } from 'workbox-precaching'
import { registerRoute } from 'workbox-routing'
import { CacheFirst, NetworkOnly, type Strategy } from 'workbox-strategies'
import { openNotification, showPush } from './push/notification'
import { createApiStrategy } from './sw/apiStrategy'
import {
cacheNames,
@@ -113,3 +114,18 @@ self.addEventListener('activate', (event) => {
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') void self.skipWaiting()
})
// Web Push: src/push/notification.ts decides what to show and where a tap
// goes; these listeners only hand it the worker's globals. A payload that is
// not a MealQuest notice still shows a generic notification, and the reason
// is logged.
self.addEventListener('push', (event) => {
event.waitUntil(
showPush(event.data?.text() ?? null, self.registration, self.location.origin, (message) => console.warn(message)),
)
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
event.waitUntil(openNotification(event.notification.data, self.clients, self.location.origin))
})
+10
View File
@@ -44,3 +44,13 @@ describe('service worker source', () => {
)
})
})
describe('service worker push listeners source', () => {
it('delegates push and notificationclick to src/push/notification.ts', () => {
expect(source).toContain("import { openNotification, showPush } from './push/notification'")
expect(source).toMatch(/self\.addEventListener\('push', \(event\) => \{\s*event\.waitUntil\(\s*showPush\(event\.data\?\.text\(\) \?\? null, self\.registration, self\.location\.origin,/)
expect(source).toMatch(
/self\.addEventListener\('notificationclick', \(event\) => \{\s*event\.notification\.close\(\)\s*event\.waitUntil\(openNotification\(event\.notification\.data, self\.clients, self\.location\.origin\)\)/,
)
})
})