rastrillo / aviso Public

Add the schema, Config and Service

One table, no users-table foreign key (apps own identity), key id on
every row so a rotated VAPID key is visible per subscription. New
refuses an empty key instead of minting one: a key minted at boot is a
key lost at the next restore. Contact is validated as given and kept
mailto-stripped for the wire, because webpush-go prefixes mailto:
itself and would otherwise send "mailto:mailto:…" as the JWT subject.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 940027d303e03c2e478f955593fe911c0b537509 parent 8f87a42
7 files changed, +350 −0
  • aviso.go +114 −0
  • aviso_test.go +64 −0
  • go.mod +20 −0
  • go.sum +73 −0
  • migrations.go +16 −0
  • migrations/0001_init.sql +17 −0
  • schema_test.go +46 −0
diff --git a/aviso.go b/aviso.go
new file mode 100644
index 0000000..29c2f35
--- /dev/null
+++ b/aviso.go
@@ -0,0 +1,114 @@
+package aviso
+
+import (
+ "database/sql"
+ "errors"
+ "log/slog"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// Config configures New. DB, PrivateKey, Contact and Origin are
+// required.
+type Config struct {
+ // DB is the app's writer. Schema must have been applied.
+ DB *sql.DB
+ // PrivateKey is the VAPID private key: unpadded base64url, 32-byte
+ // P-256 scalar, as cmd/aviso-key prints it. Provisioned, never
+ // minted here — see ErrEmptyPrivateKey.
+ PrivateKey string
+ // Contact is the VAPID "sub" claim — a mailto: or https: URL a push
+ // service may use to reach the operator about abuse.
+ Contact string
+ // Origin is the app's external origin, scheme included, for
+ // csrf.SameOrigin on the mutating handlers.
+ Origin string
+ // Concurrency bounds in-flight sends across the whole Service.
+ // 0 means 32.
+ Concurrency int
+ Logger *slog.Logger
+}
+
+// Subscription is what the browser hands the app: the push service's
+// endpoint and the two keys RFC 8291 encrypts to.
+type Subscription struct {
+ Endpoint string
+ P256dh string
+ Auth string
+}
+
+// Stored is one enrolled device: a Subscription plus its row identity.
+// Revision changes on every re-subscribe, and Send matches on it so a
+// slow send cannot prune a subscription the browser refreshed
+// meanwhile.
+type Stored struct {
+ ID string
+ Subject string
+ VAPIDKeyID string
+ Revision int64
+ Subscription
+}
+
+// ErrOwnedElsewhere is Subscribe's refusal to move an endpoint between
+// subjects: a second account on the same browser must re-enrol, not
+// silently take over the first account's device.
+var ErrOwnedElsewhere = errors.New("aviso: endpoint is enrolled by another subject")
+
+// ErrKeyMismatch marks a Result for a row enrolled under a VAPID key
+// other than this Service's: it cannot be signed for, so it is skipped
+// rather than sent to fail.
+var ErrKeyMismatch = errors.New("aviso: subscription was enrolled under a different VAPID key")
+
+// Service is the wired addon. Build one per process and share it: the
+// concurrency bound lives on it.
+type Service struct {
+ cfg Config
+ pub string
+ // keyID is SHA-256 of the public point; rows carry it, and Send
+ // skips rows that do not match rather than signing for them with
+ // a key the browser never subscribed to.
+ keyID string
+ // wireContact is Contact as webpush-go wants it: it prefixes
+ // "mailto:" itself to anything not https:, so handing it the
+ // mailto: form verbatim would produce "mailto:mailto:…".
+ wireContact string
+ client *http.Client // built by newClient (ssrf.go); tests may replace it
+ sem chan struct{}
+ now func() time.Time
+}
+
+// New validates cfg and returns a ready *Service.
+func New(cfg Config) (*Service, error) {
+ if cfg.DB == nil {
+ return nil, errors.New("aviso: Config.DB is required")
+ }
+ pub, keyID, err := parsePrivateKey(cfg.PrivateKey)
+ if err != nil {
+ return nil, err
+ }
+ if !strings.HasPrefix(cfg.Contact, "mailto:") && !strings.HasPrefix(cfg.Contact, "https://") {
+ return nil, errors.New("aviso: Config.Contact must be a mailto: or https: URL")
+ }
+ if !strings.HasPrefix(cfg.Origin, "https://") && !strings.HasPrefix(cfg.Origin, "http://") {
+ return nil, errors.New("aviso: Config.Origin must be an absolute origin like https://app.example.com")
+ }
+ if cfg.Concurrency <= 0 {
+ cfg.Concurrency = 32
+ }
+ if cfg.Logger == nil {
+ cfg.Logger = slog.Default()
+ }
+ return &Service{
+ cfg: cfg,
+ pub: pub,
+ keyID: keyID,
+ wireContact: strings.TrimPrefix(cfg.Contact, "mailto:"),
+ sem: make(chan struct{}, cfg.Concurrency),
+ now: time.Now,
+ }, nil
+}
+
+// PublicKeyString is the applicationServerKey the browser subscribes
+// with: unpadded base64url of the uncompressed P-256 point.
+func (s *Service) PublicKeyString() string { return s.pub }
diff --git a/aviso_test.go b/aviso_test.go
new file mode 100644
index 0000000..1020ff5
--- /dev/null
+++ b/aviso_test.go
@@ -0,0 +1,64 @@
+package aviso_test
+
+import (
+ "errors"
+ "testing"
+
+ "amadan.net/rastrillo/aviso"
+)
+
+func newService(t *testing.T) *aviso.Service {
+ t.Helper()
+ key, err := aviso.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ s, err := aviso.New(aviso.Config{
+ DB: openDB(t), PrivateKey: key,
+ Contact: "mailto:ops@example.test", Origin: "https://app.example.test",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return s
+}
+
+func TestNewRefusesBadConfig(t *testing.T) {
+ key, _ := aviso.GenerateKey()
+ good := aviso.Config{DB: openDB(t), PrivateKey: key, Contact: "mailto:x@y", Origin: "https://a"}
+ if _, err := aviso.New(good); err != nil {
+ t.Fatalf("good config refused: %v", err)
+ }
+ c := good
+ c.PrivateKey = ""
+ if _, err := aviso.New(c); !errors.Is(err, aviso.ErrEmptyPrivateKey) {
+ t.Errorf("empty key: %v", err)
+ }
+ c = good
+ c.PrivateKey = "not-a-key"
+ if _, err := aviso.New(c); !errors.Is(err, aviso.ErrInvalidPrivateKey) {
+ t.Errorf("bad key: %v", err)
+ }
+ c = good
+ c.DB = nil
+ if _, err := aviso.New(c); err == nil {
+ t.Error("nil DB accepted")
+ }
+ c = good
+ c.Contact = "ops@example.test"
+ if _, err := aviso.New(c); err == nil {
+ t.Error("bare address accepted as Contact")
+ }
+ c = good
+ c.Origin = "app.example.test"
+ if _, err := aviso.New(c); err == nil {
+ t.Error("schemeless Origin accepted")
+ }
+}
+
+func TestPublicKeyStringIsStable(t *testing.T) {
+ s := newService(t)
+ if s.PublicKeyString() == "" {
+ t.Fatal("empty public key")
+ }
+}
diff --git a/go.mod b/go.mod
index 2c5e035..e513ce0 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,23 @@
module amadan.net/rastrillo/aviso
go 1.25.0
+
+require amadan.net/rastrillo/rastrillo v0.26.0
+
+require (
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/jinzhu/inflection v1.0.0 // indirect
+ github.com/jinzhu/now v1.1.5 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/ncruces/go-strftime v1.0.0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ golang.org/x/sys v0.46.0 // indirect
+ golang.org/x/text v0.20.0 // indirect
+ gorm.io/gorm v1.31.2 // indirect
+ gorm.io/plugin/dbresolver v1.6.2 // indirect
+ modernc.org/libc v1.74.1 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.11.0 // indirect
+ modernc.org/sqlite v1.55.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..2e62e67
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,73 @@
+amadan.net/rastrillo/rastrillo v0.26.0 h1:I7UkiDbT304q9wXnmgabe84P6RDFTjMbKU174i1QZDo=
+amadan.net/rastrillo/rastrillo v0.26.0/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
+github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/keymaildev/signin v0.1.1 h1:gO+1IAM99sqUkBtCezkgGxab+/DOrqwDxd5H32N1s9Q=
+github.com/keymaildev/signin v0.1.1/go.mod h1:Eb/sCmEel1jlcdkgPOrNeMn5jvxzoFvJrdjDUxOBHls=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
+github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
+golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
+golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
+golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
+golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
+golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
+gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
+gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
+gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
+gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
+gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
+gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc=
+gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM=
+modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
+modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
+modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
+modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
+modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
+modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
+modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
+modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/migrations.go b/migrations.go
new file mode 100644
index 0000000..05203fe
--- /dev/null
+++ b/migrations.go
@@ -0,0 +1,16 @@
+package aviso
+
+import (
+ "embed"
+
+ "amadan.net/rastrillo/rastrillo/migrate"
+)
+
+//go:embed migrations/*.sql
+var migrationFS embed.FS
+
+// Schema is the addon's migration set. Merge it into the app's
+// BootSchema, never its Schema: `rastrillo migration check` diffs
+// Schema against Models and would propose dropping a table Models
+// does not know about.
+var Schema = migrate.MustFromFS(migrationFS, "aviso")
diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql
new file mode 100644
index 0000000..1fe1c51
--- /dev/null
+++ b/migrations/0001_init.sql
@@ -0,0 +1,17 @@
+-- One row per browser subscription. `endpoint` is the push service's
+-- unguessable URL and is unique by construction; `subject` is the
+-- rastrillo session subject that enrolled it. No foreign key to any
+-- users table: apps own their identity schema.
+CREATE TABLE aviso_subscriptions (
+ id TEXT NOT NULL PRIMARY KEY,
+ endpoint TEXT NOT NULL UNIQUE,
+ subject TEXT NOT NULL CHECK (length(subject) > 0),
+ p256dh TEXT NOT NULL,
+ auth TEXT NOT NULL,
+ vapid_key_id TEXT NOT NULL,
+ revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
+ created_at INTEGER NOT NULL,
+ last_confirmed_at INTEGER NOT NULL
+);
+CREATE INDEX aviso_subscriptions_subject ON aviso_subscriptions(subject);
+CREATE INDEX aviso_subscriptions_confirmed ON aviso_subscriptions(last_confirmed_at);
diff --git a/schema_test.go b/schema_test.go
new file mode 100644
index 0000000..059851c
--- /dev/null
+++ b/schema_test.go
@@ -0,0 +1,46 @@
+package aviso_test
+
+import (
+ "context"
+ "database/sql"
+ "path/filepath"
+ "testing"
+
+ "amadan.net/rastrillo/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/migrate"
+
+ "amadan.net/rastrillo/aviso"
+)
+
+// openDB is a fresh on-disk rastrillo db with aviso.Schema applied —
+// what every store and handler test starts from.
+func openDB(t *testing.T) *sql.DB {
+ t.Helper()
+ d, err := db.Open(filepath.Join(t.TempDir(), "aviso.db"), nil)
+ if err != nil {
+ t.Fatalf("db.Open: %v", err)
+ }
+ t.Cleanup(func() { d.Close() })
+ if _, err := migrate.Apply(context.Background(), d, aviso.Schema); err != nil {
+ t.Fatalf("migrate.Apply: %v", err)
+ }
+ return d.Writer()
+}
+
+func TestSchemaCreatesTheTableAndReplaysCleanly(t *testing.T) {
+ d, err := db.Open(filepath.Join(t.TempDir(), "aviso.db"), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer d.Close()
+ for i := 0; i < 2; i++ { // second Apply must be a no-op, not a failure
+ if _, err := migrate.Apply(context.Background(), d, aviso.Schema); err != nil {
+ t.Fatalf("apply %d: %v", i, err)
+ }
+ }
+ var name string
+ err = d.Writer().QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name='aviso_subscriptions'`).Scan(&name)
+ if err != nil {
+ t.Fatalf("table missing: %v", err)
+ }
+}