rastrillo / aviso Public

Add the example app: every seam wired, everyone signed in as dev

Its own module with a replace back to the addon, so `go test ./...`
at the root does not reach it and the Makefile visits it explicitly.
It serves the two embedded JS files and its own worker as
text/javascript with no-cache, mounts the three handlers, awaits the
worker's ready before reconciling, and gates its notify route with
csrf.SameOrigin the way any state-changing POST in a rastrillo app is
gated. The test proves each seam answers and a subscribe lands as
"dev".
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 3bf45d1b5584297e8b75b670d1fc2c010f7e1515 parent 2b283e1
9 files changed, +513 −0
  • Makefile +4 −0
  • example/go.mod +31 −0
  • example/go.sum +141 −0
  • example/index.html +28 −0
  • example/main.go +121 −0
  • example/main_test.go +110 −0
  • example/static/app.js +46 −0
  • example/static/manifest.webmanifest +11 −0
  • example/static/sw.js +21 −0
diff --git a/Makefile b/Makefile
index 7edbc81..9276d6f 100644
--- a/Makefile
+++ b/Makefile
@@ -10,8 +10,11 @@
# it cannot run.
ci: vet fmt-check test race
+# The example is its own module with a replace back here, so ./...
+# does not reach it; each target visits it explicitly.
vet:
go vet ./...
+ cd example && go vet ./...
fmt-check:
@out=$$(gofmt -l .); if [ -n "$$out" ]; then echo "gofmt needed:"; echo "$$out"; exit 1; fi
@@ -21,6 +24,7 @@ fmt-check:
# as though it were every scheduling.
test:
go test ./... -count=1
+ cd example && go test ./... -count=1
# -race needs cgo; everything else runs with the default toolchain, so
# this target sets CGO_ENABLED for its own command only.
diff --git a/example/go.mod b/example/go.mod
new file mode 100644
index 0000000..5f9ebec
--- /dev/null
+++ b/example/go.mod
@@ -0,0 +1,31 @@
+module example
+
+go 1.25.0
+
+require (
+ amadan.net/rastrillo/aviso v0.0.0
+ amadan.net/rastrillo/rastrillo v0.26.0
+)
+
+require (
+ github.com/SherClockHolmes/webpush-go v1.4.0 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/golang-jwt/jwt/v5 v5.2.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/crypto v0.31.0 // indirect
+ golang.org/x/sys v0.46.0 // indirect
+ golang.org/x/text v0.21.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
+)
+
+replace amadan.net/rastrillo/aviso => ../
diff --git a/example/go.sum b/example/go.sum
new file mode 100644
index 0000000..7c585c7
--- /dev/null
+++ b/example/go.sum
@@ -0,0 +1,141 @@
+amadan.net/rastrillo/rastrillo v0.26.0 h1:I7UkiDbT304q9wXnmgabe84P6RDFTjMbKU174i1QZDo=
+amadan.net/rastrillo/rastrillo v0.26.0/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE=
+github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
+github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
+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/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
+github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/pprof v0.0.0-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=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
+golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
+golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
+golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
+golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
+golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
+golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
+golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
+golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
+golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
+golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
+golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
+golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
+golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
+golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
+golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+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/example/index.html b/example/index.html
new file mode 100644
index 0000000..e8ae234
--- /dev/null
+++ b/example/index.html
@@ -0,0 +1,28 @@
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>aviso example</title>
+ <link rel="manifest" href="/static/manifest.webmanifest">
+ <meta name="theme-color" content="#5b6cff">
+ <style>
+ body { font: 16px/1.5 system-ui, sans-serif; margin: 2rem auto; max-width: 40rem; padding: 0 1rem; }
+ button { font: inherit; padding: .5rem 1rem; margin-right: .5rem; }
+ pre { background: #f4f4f6; padding: 1rem; overflow-x: auto; }
+ #coach { display: none; border-left: 3px solid #5b6cff; padding-left: 1rem; }
+ </style>
+</head>
+<body>
+ <h1>aviso example</h1>
+ <p>Everyone here is signed in as <code>dev</code>. Enable push, then notify yourself.</p>
+ <p id="coach">On a phone, add this app to your Home Screen and open it from there first — push is only delivered to an installed app.</p>
+ <p>
+ <button id="enable">Enable notifications</button>
+ <button id="disable">Disable</button>
+ <button id="notify">Notify me</button>
+ </p>
+ <pre id="log"></pre>
+ <script type="module" src="/static/app.js"></script>
+</body>
+</html>
diff --git a/example/main.go b/example/main.go
new file mode 100644
index 0000000..8d72e2c
--- /dev/null
+++ b/example/main.go
@@ -0,0 +1,121 @@
+// Command example is the smallest app that wires every aviso seam:
+// the schema, the three handlers, the two JS halves, a page with an
+// enable button, and a route that sends. It signs everyone in as
+// "dev" — an example, not a pattern — so the enrol/send loop can be
+// driven from one browser.
+//
+// EXAMPLE_VAPID_PRIVATE_KEY="$(go run amadan.net/rastrillo/aviso/cmd/aviso-key)" go run .
+//
+// Push needs a secure context: http://localhost is one, so the default
+// origin works locally without TLS.
+package main
+
+import (
+ "context"
+ "embed"
+ "encoding/json"
+ "io/fs"
+ "log"
+ "net/http"
+ "os"
+ "time"
+
+ "amadan.net/rastrillo/rastrillo/csrf"
+ "amadan.net/rastrillo/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/migrate"
+ "amadan.net/rastrillo/rastrillo/sessions"
+
+ "amadan.net/rastrillo/aviso"
+)
+
+//go:embed index.html static/*
+var site embed.FS
+
+func serveBytes(contentType string, b []byte) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", contentType)
+ w.Header().Set("Cache-Control", "no-cache")
+ _, _ = w.Write(b)
+ }
+}
+
+func handler(svc *aviso.Service, origin string) http.Handler {
+ mux := http.NewServeMux()
+ static, _ := fs.Sub(site, "static")
+ index, _ := site.ReadFile("index.html")
+ sw, _ := site.ReadFile("static/sw.js")
+
+ mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(static))))
+ mux.HandleFunc("GET /static/aviso/push.mjs", serveBytes("text/javascript", aviso.JS()))
+ mux.HandleFunc("GET /static/aviso/aviso-sw.js", serveBytes("text/javascript", aviso.WorkerJS()))
+ // The worker at root scope, no-cache so a new one is noticed on
+ // the next load rather than after a cache expiry nobody chose.
+ mux.HandleFunc("GET /sw.js", serveBytes("text/javascript", sw))
+ mux.HandleFunc("GET /{$}", serveBytes("text/html; charset=utf-8", index))
+
+ mux.HandleFunc("GET /aviso/public-key", svc.PublicKey)
+ mux.HandleFunc("POST /aviso/subscribe", svc.Subscribe)
+ mux.HandleFunc("POST /aviso/unsubscribe", svc.Unsubscribe)
+
+ // The app's own policy: who gets what. Here, the caller, now. Gated
+ // like any state-changing POST in a rastrillo app.
+ mux.HandleFunc("POST /notify", func(w http.ResponseWriter, r *http.Request) {
+ if !csrf.SameOrigin(r, origin) {
+ http.Error(w, "cross-origin request refused", http.StatusForbidden)
+ return
+ }
+ sess, _ := sessions.Current(r)
+ payload, _ := json.Marshal(map[string]string{
+ "title": "Hello from aviso",
+ "body": "Sent at " + time.Now().Format(time.Kitchen),
+ "url": "/",
+ "tag": "example",
+ })
+ res, err := svc.SendTo(r.Context(), sess.Subject, payload, aviso.Options{TTL: 60 * time.Second})
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ out := make([]map[string]any, 0, len(res))
+ for _, x := range res {
+ m := map[string]any{"id": x.ID, "status": x.Status}
+ if x.Err != nil {
+ m["error"] = x.Err.Error()
+ }
+ out = append(out, m)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(out)
+ })
+
+ // Example-only: everyone is "dev". A real app runs
+ // sessions.Middleware (or auth.RequireSession) here instead.
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mux.ServeHTTP(w, sessions.WithSession(r, sessions.Session{Subject: "dev"}))
+ })
+}
+
+func main() {
+ origin := os.Getenv("ORIGIN")
+ if origin == "" {
+ origin = "http://localhost:8080"
+ }
+ d, err := db.Open("example.db", nil)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if _, err := migrate.Apply(context.Background(), d, migrate.Merge(sessions.Schema, aviso.Schema)); err != nil {
+ log.Fatal(err)
+ }
+ svc, err := aviso.New(aviso.Config{
+ DB: d.Writer(),
+ PrivateKey: os.Getenv("EXAMPLE_VAPID_PRIVATE_KEY"),
+ Contact: "mailto:ops@example.test",
+ Origin: origin,
+ })
+ if err != nil {
+ log.Fatal(err)
+ }
+ log.Println("listening on :8080 as", origin)
+ log.Fatal(http.ListenAndServe(":8080", handler(svc, origin)))
+}
diff --git a/example/main_test.go b/example/main_test.go
new file mode 100644
index 0000000..95e8522
--- /dev/null
+++ b/example/main_test.go
@@ -0,0 +1,110 @@
+package main
+
+import (
+ "context"
+ "crypto/ecdh"
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "amadan.net/rastrillo/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/migrate"
+ "amadan.net/rastrillo/rastrillo/sessions"
+
+ "amadan.net/rastrillo/aviso"
+)
+
+func newApp(t *testing.T) (http.Handler, *aviso.Service) {
+ t.Helper()
+ d, err := db.Open(filepath.Join(t.TempDir(), "example.db"), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { d.Close() })
+ if _, err := migrate.Apply(context.Background(), d, migrate.Merge(sessions.Schema, aviso.Schema)); err != nil {
+ t.Fatal(err)
+ }
+ key, _ := aviso.GenerateKey()
+ svc, err := aviso.New(aviso.Config{DB: d.Writer(), PrivateKey: key, Contact: "mailto:ops@example.test", Origin: "http://localhost:8080"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return handler(svc, "http://localhost:8080"), svc
+}
+
+func get(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
+ t.Helper()
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
+ return w
+}
+
+// Every seam answers: the page, the two embedded JS files, the
+// worker at root scope, the key, and a subscribe that lands as "dev".
+func TestEverySeamIsWired(t *testing.T) {
+ h, svc := newApp(t)
+ for path, want := range map[string]string{
+ "/": "<button id=\"enable\"",
+ "/static/app.js": "import { enable",
+ "/static/aviso/push.mjs": "export async function enable(",
+ "/static/aviso/aviso-sw.js": "AvisoSW",
+ "/sw.js": "importScripts(\"/static/aviso/aviso-sw.js\")",
+ "/aviso/public-key": "publicKey",
+ "/static/manifest.webmanifest": "\"display\": \"standalone\"",
+ } {
+ w := get(t, h, path)
+ if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), want) {
+ t.Errorf("%s: %d, body lacks %q", path, w.Code, want)
+ }
+ }
+ if cc := get(t, h, "/sw.js").Header().Get("Cache-Control"); cc != "no-cache" {
+ t.Errorf("sw.js Cache-Control = %q", cc)
+ }
+ // A module or worker script served as text/plain is refused by the
+ // browser; the example must model the right content type.
+ for _, path := range []string{"/static/aviso/push.mjs", "/static/aviso/aviso-sw.js", "/sw.js", "/static/app.js"} {
+ if ct := get(t, h, path).Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") {
+ t.Errorf("%s Content-Type = %q", path, ct)
+ }
+ }
+
+ k, _ := ecdh.P256().GenerateKey(rand.Reader)
+ auth := make([]byte, 16)
+ _, _ = rand.Read(auth)
+ body, _ := json.Marshal(map[string]any{
+ "subscription": map[string]any{
+ "endpoint": "https://push.example/dev-phone",
+ "keys": map[string]string{
+ "p256dh": base64.RawURLEncoding.EncodeToString(k.PublicKey().Bytes()),
+ "auth": base64.RawURLEncoding.EncodeToString(auth),
+ },
+ },
+ "publicKey": svc.PublicKeyString(),
+ })
+ r := httptest.NewRequest(http.MethodPost, "/aviso/subscribe", strings.NewReader(string(body)))
+ r.Header.Set("Content-Type", "application/json")
+ r.Header.Set("Sec-Fetch-Site", "same-origin")
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ if w.Code != http.StatusNoContent {
+ t.Fatalf("subscribe: %d %s", w.Code, w.Body)
+ }
+ rows, err := svc.List(context.Background(), "dev")
+ if err != nil || len(rows) != 1 {
+ t.Fatalf("rows for dev: %v %+v", err, rows)
+ }
+
+ // notify is gated like any state-changing POST.
+ r = httptest.NewRequest(http.MethodPost, "/notify", nil)
+ r.Header.Set("Sec-Fetch-Site", "cross-site")
+ w = httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ if w.Code != http.StatusForbidden {
+ t.Fatalf("cross-site notify: %d", w.Code)
+ }
+}
diff --git a/example/static/app.js b/example/static/app.js
new file mode 100644
index 0000000..bb02443
--- /dev/null
+++ b/example/static/app.js
@@ -0,0 +1,46 @@
+// The page half, as SKILL.md shows it.
+import { enable, reconcile, disable, capabilities, status } from "/static/aviso/push.mjs";
+
+const log = (line) => { document.getElementById("log").textContent += line + "\n"; };
+const post = (path) => (body) => fetch(path, {
+ method: "POST", credentials: "same-origin",
+ headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
+});
+const save = post("/aviso/subscribe");
+const remove = post("/aviso/unsubscribe");
+
+const caps = capabilities();
+log("capabilities: " + JSON.stringify(caps));
+if (!caps.standalone && /iPhone|iPad/.test(navigator.userAgent)) {
+ document.getElementById("coach").style.display = "block";
+}
+if (!caps.serviceWorker || !caps.push) {
+ log("this browser cannot do push");
+} else {
+ await navigator.serviceWorker.register("/sw.js");
+ // Active, not merely registered: subscribe() on an installing
+ // worker rejects with InvalidStateError.
+ const registration = await navigator.serviceWorker.ready;
+ const { publicKey } = await (await fetch("/aviso/public-key")).json();
+
+ // Every load: repair without prompting, and keep the server's
+ // confirmation moving.
+ const repaired = await reconcile({ registration, publicKey, save }).catch((e) => { log("reconcile: " + e.message); return null; });
+ log("subscribed: " + !!repaired);
+
+ // From the click, synchronously: enable prompts before its first await.
+ document.getElementById("enable").onclick = () => {
+ enable({ registration, publicKey, save })
+ .then((sub) => log(sub ? "enabled: " + sub.endpoint.slice(0, 40) + "…" : "permission denied"))
+ .catch((e) => log("enable: " + e.message));
+ };
+ document.getElementById("disable").onclick = async () => {
+ try { await disable({ registration, remove }); log("disabled"); } catch (e) { log("disable: " + e.message); }
+ };
+ document.getElementById("notify").onclick = async () => {
+ const r = await fetch("/notify", { method: "POST", credentials: "same-origin" });
+ log("notify: " + r.status + " " + await r.text());
+ };
+ const s = await status(registration);
+ log("permission: " + s.permission);
+}
diff --git a/example/static/manifest.webmanifest b/example/static/manifest.webmanifest
new file mode 100644
index 0000000..bdbb5de
--- /dev/null
+++ b/example/static/manifest.webmanifest
@@ -0,0 +1,11 @@
+{
+ "id": "/",
+ "name": "aviso example",
+ "short_name": "aviso",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "theme_color": "#5b6cff",
+ "background_color": "#f6f7fb",
+ "icons": []
+}
diff --git a/example/static/sw.js b/example/static/sw.js
new file mode 100644
index 0000000..8902a9a
--- /dev/null
+++ b/example/static/sw.js
@@ -0,0 +1,21 @@
+// The app's worker, as SKILL.md shows it. Lifecycle is the app's:
+// nothing here calls skipWaiting or clients.claim either, because this
+// example has nothing to claim.
+importScripts("/static/aviso/aviso-sw.js");
+
+const post = (body) => fetch("/aviso/subscribe", {
+ method: "POST", credentials: "same-origin", mode: "same-origin", redirect: "error",
+ headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
+});
+
+self.addEventListener("push", (e) => e.waitUntil(AvisoSW.handlePush(e, {
+ fallback: () => ({ title: "aviso example", options: { body: "Something happened.", data: { url: "/" } } }),
+})));
+
+self.addEventListener("notificationclick", (e) => e.waitUntil(AvisoSW.handleClick(e, { fallbackURL: "/" })));
+
+self.addEventListener("pushsubscriptionchange", (e) => e.waitUntil(AvisoSW.handleSubscriptionChange(e, {
+ // Fetched on demand: a terminated worker forgets its variables.
+ publicKey: () => fetch("/aviso/public-key").then((r) => r.json()).then((j) => j.publicKey),
+ save: post,
+})));