summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorojacobson <ojacobson@noreply.codeberg.org>2025-11-07 23:17:15 +0100
committerojacobson <ojacobson@noreply.codeberg.org>2025-11-07 23:17:15 +0100
commit9e6f19f0f188eaa7f8b6be21c8405786cfb0dddd (patch)
treeb2999341645dec61e8143d7bb1b8a9d0056e0db1
parent3c588861ef5814de329743147398dbae22c1aeeb (diff)
parent78d901328261d2306cf59c8e83fc217a63aa4a64 (diff)
Set up infrastructure for push message subscriptions.
A subscription allows an application server (here, the Pilcrow server) to send web push messages to a user agent. On the server, Pilcrow records subscriptions verbatim, in the clear. Each subscription has an associated key, which will be used to encrypt messages for the corresponding client, but we store them in the clear, for the same broad reason that we store the VAPID key in the clear. They allow anyone who obtains them to impersonate the server and send push messages to clients, but they're rotated regularly - clients must rotate them whenever the server's VAPID key changes. On the client, we monitor VAPID key change events to drive automatic subscription management, once the user sets up an initial subscription manually (which we must do as it can involve a user-interaction-only prompt for permission to send notifications). This isn't the final UI, but rather a bare-minimum version to let us move on with testing push notifications. Merges push-subscribe into push-notify.
-rw-r--r--.sqlx/query-3e008c1231d35b4c0675d2830f025325f44d5eb8ddbc573fe134a3deb28e04b9.json12
-rw-r--r--.sqlx/query-3f42f859ded1a8b6184ee2c8a11099fefbd148d52705a834d7db4cbcc6fdd682.json12
-rw-r--r--.sqlx/query-46c94988ed02829744cff50bcbfa9b4638273c89ceafd0790c7c372e6954619a.json12
-rw-r--r--.sqlx/query-a86531c0ed384706645a19535179504b0d354431a66a870a4d457c028da3c584.json12
-rw-r--r--.sqlx/query-b361b2ac9085881d74f5567572e2ed30d72997edad1a3c1e7c8e10c7933c9c72.json12
-rw-r--r--.sqlx/query-f4bf2e6a701f7374770f79fabf83c405f7f7a88f2cbf374c0ea1682af9743004.json32
-rw-r--r--Cargo.lock911
-rw-r--r--Cargo.toml1
-rw-r--r--docs/api/SUMMARY.md1
-rw-r--r--docs/api/authentication.md4
-rw-r--r--docs/api/basics.md2
-rw-r--r--docs/api/push.md124
-rw-r--r--migrations/20251009021241_push_subscriptions.sql12
-rw-r--r--src/app.rs11
-rw-r--r--src/lib.rs1
-rw-r--r--src/login/app.rs2
-rw-r--r--src/push/app.rs76
-rw-r--r--src/push/handlers/mod.rs3
-rw-r--r--src/push/handlers/subscribe/mod.rs95
-rw-r--r--src/push/handlers/subscribe/test.rs236
-rw-r--r--src/push/mod.rs3
-rw-r--r--src/push/repo.rs114
-rw-r--r--src/routes.rs3
-rw-r--r--src/token/app.rs3
-rw-r--r--src/token/repo/token.rs17
-rw-r--r--src/vapid/app.rs5
-rw-r--r--src/vapid/event.rs13
-rw-r--r--src/vapid/middleware.rs6
-rw-r--r--src/vapid/mod.rs1
-rw-r--r--src/vapid/ser.rs63
-rw-r--r--ui/lib/apiServer.js4
-rw-r--r--ui/lib/components/PushSubscription.svelte27
-rw-r--r--ui/lib/session.svelte.js19
-rw-r--r--ui/lib/state/local/push.svelte.js141
-rw-r--r--ui/lib/state/remote/state.svelte.js15
-rw-r--r--ui/routes/(app)/me/+page.svelte12
36 files changed, 1909 insertions, 108 deletions
diff --git a/.sqlx/query-3e008c1231d35b4c0675d2830f025325f44d5eb8ddbc573fe134a3deb28e04b9.json b/.sqlx/query-3e008c1231d35b4c0675d2830f025325f44d5eb8ddbc573fe134a3deb28e04b9.json
new file mode 100644
index 0000000..be1779a
--- /dev/null
+++ b/.sqlx/query-3e008c1231d35b4c0675d2830f025325f44d5eb8ddbc573fe134a3deb28e04b9.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "SQLite",
+ "query": "\n with stale_tokens as (\n select id from token\n where last_used_at < $1\n )\n delete from push_subscription\n where token in stale_tokens\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Right": 1
+ },
+ "nullable": []
+ },
+ "hash": "3e008c1231d35b4c0675d2830f025325f44d5eb8ddbc573fe134a3deb28e04b9"
+}
diff --git a/.sqlx/query-3f42f859ded1a8b6184ee2c8a11099fefbd148d52705a834d7db4cbcc6fdd682.json b/.sqlx/query-3f42f859ded1a8b6184ee2c8a11099fefbd148d52705a834d7db4cbcc6fdd682.json
new file mode 100644
index 0000000..abd04ac
--- /dev/null
+++ b/.sqlx/query-3f42f859ded1a8b6184ee2c8a11099fefbd148d52705a834d7db4cbcc6fdd682.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "SQLite",
+ "query": "\n with tokens as (\n select id from token\n where login = $1\n )\n delete from push_subscription\n where token in tokens\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Right": 1
+ },
+ "nullable": []
+ },
+ "hash": "3f42f859ded1a8b6184ee2c8a11099fefbd148d52705a834d7db4cbcc6fdd682"
+}
diff --git a/.sqlx/query-46c94988ed02829744cff50bcbfa9b4638273c89ceafd0790c7c372e6954619a.json b/.sqlx/query-46c94988ed02829744cff50bcbfa9b4638273c89ceafd0790c7c372e6954619a.json
new file mode 100644
index 0000000..ab98270
--- /dev/null
+++ b/.sqlx/query-46c94988ed02829744cff50bcbfa9b4638273c89ceafd0790c7c372e6954619a.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "SQLite",
+ "query": "\n delete from push_subscription\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Right": 0
+ },
+ "nullable": []
+ },
+ "hash": "46c94988ed02829744cff50bcbfa9b4638273c89ceafd0790c7c372e6954619a"
+}
diff --git a/.sqlx/query-a86531c0ed384706645a19535179504b0d354431a66a870a4d457c028da3c584.json b/.sqlx/query-a86531c0ed384706645a19535179504b0d354431a66a870a4d457c028da3c584.json
new file mode 100644
index 0000000..8a80025
--- /dev/null
+++ b/.sqlx/query-a86531c0ed384706645a19535179504b0d354431a66a870a4d457c028da3c584.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "SQLite",
+ "query": "\n insert into push_subscription (token, endpoint, p256dh, auth)\n values ($1, $2, $3, $4)\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Right": 4
+ },
+ "nullable": []
+ },
+ "hash": "a86531c0ed384706645a19535179504b0d354431a66a870a4d457c028da3c584"
+}
diff --git a/.sqlx/query-b361b2ac9085881d74f5567572e2ed30d72997edad1a3c1e7c8e10c7933c9c72.json b/.sqlx/query-b361b2ac9085881d74f5567572e2ed30d72997edad1a3c1e7c8e10c7933c9c72.json
new file mode 100644
index 0000000..5e004a3
--- /dev/null
+++ b/.sqlx/query-b361b2ac9085881d74f5567572e2ed30d72997edad1a3c1e7c8e10c7933c9c72.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "SQLite",
+ "query": "\n delete from push_subscription\n where token = $1\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Right": 1
+ },
+ "nullable": []
+ },
+ "hash": "b361b2ac9085881d74f5567572e2ed30d72997edad1a3c1e7c8e10c7933c9c72"
+}
diff --git a/.sqlx/query-f4bf2e6a701f7374770f79fabf83c405f7f7a88f2cbf374c0ea1682af9743004.json b/.sqlx/query-f4bf2e6a701f7374770f79fabf83c405f7f7a88f2cbf374c0ea1682af9743004.json
new file mode 100644
index 0000000..3e50a4b
--- /dev/null
+++ b/.sqlx/query-f4bf2e6a701f7374770f79fabf83c405f7f7a88f2cbf374c0ea1682af9743004.json
@@ -0,0 +1,32 @@
+{
+ "db_name": "SQLite",
+ "query": "\n select\n subscription.endpoint,\n subscription.p256dh,\n subscription.auth\n from push_subscription as subscription\n join token on subscription.token = token.id\n join login as subscriber on token.login = subscriber.id\n where subscriber.id = $1\n and subscription.endpoint = $2\n ",
+ "describe": {
+ "columns": [
+ {
+ "name": "endpoint",
+ "ordinal": 0,
+ "type_info": "Text"
+ },
+ {
+ "name": "p256dh",
+ "ordinal": 1,
+ "type_info": "Text"
+ },
+ {
+ "name": "auth",
+ "ordinal": 2,
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Right": 2
+ },
+ "nullable": [
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "f4bf2e6a701f7374770f79fabf83c405f7f7a88f2cbf374c0ea1682af9743004"
+}
diff --git a/Cargo.lock b/Cargo.lock
index 0f9ece9..63a36ec 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -30,6 +30,15 @@ dependencies = [
]
[[package]]
+name = "aho-corasick"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -101,6 +110,12 @@ dependencies = [
]
[[package]]
+name = "anyhow"
+version = "1.0.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
+
+[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -113,6 +128,40 @@ dependencies = [
]
[[package]]
+name = "arrayref"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
+
+[[package]]
+name = "arrayvec"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+
+[[package]]
+name = "async-channel"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35"
+dependencies = [
+ "concurrent-queue",
+ "event-listener 2.5.3",
+ "futures-core",
+]
+
+[[package]]
+name = "async-trait"
+version = "0.1.89"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.98",
+]
+
+[[package]]
name = "atoi"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -137,7 +186,7 @@ dependencies = [
"bytes",
"form_urlencoded",
"futures-util",
- "http",
+ "http 1.2.0",
"http-body",
"http-body-util",
"hyper",
@@ -169,7 +218,7 @@ checksum = "df1362f362fd16024ae199c1970ce98f9661bf5ef94b9808fee734bc3698b733"
dependencies = [
"bytes",
"futures-util",
- "http",
+ "http 1.2.0",
"http-body",
"http-body-util",
"mime",
@@ -194,7 +243,7 @@ dependencies = [
"form_urlencoded",
"futures-util",
"headers",
- "http",
+ "http 1.2.0",
"http-body",
"http-body-util",
"mime",
@@ -230,6 +279,12 @@ checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]]
name = "base64"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
+
+[[package]]
+name = "base64"
version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
@@ -247,6 +302,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
[[package]]
+name = "binstring"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0669d5a35b64fdb5ab7fb19cae13148b6b5cbdf4b8247faf54ece47f699c8cef"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
name = "bitflags"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -265,6 +332,17 @@ dependencies = [
]
[[package]]
+name = "blake2b_simd"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06e903a20b159e944f91ec8499fe1e55651480c541ea0a584f5d967c49ad9d99"
+dependencies = [
+ "arrayref",
+ "arrayvec",
+ "constant_time_eq",
+]
+
+[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -292,6 +370,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f61dac84819c6588b558454b194026eb1f09c293b9036ae9b159e74e73ab6cf9"
[[package]]
+name = "castaway"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2698f953def977c68f935bb0dfa959375ad4638570e969e2f1e9f433cbf1af6"
+
+[[package]]
name = "cc"
version = "1.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -358,7 +442,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.98",
]
[[package]]
@@ -368,6 +452,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"
[[package]]
+name = "coarsetime"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91849686042de1b41cd81490edc83afbcb0abe5a9b6f2c4114f23ce8cca1bcf4"
+dependencies = [
+ "libc",
+ "wasix",
+ "wasm-bindgen",
+]
+
+[[package]]
name = "colorchoice"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -384,11 +479,23 @@ dependencies = [
[[package]]
name = "const-oid"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d6f2aa4d0537bcc1c74df8755072bd31c1ef1a3a1b85a68e8404a8c353b7b8b"
+
+[[package]]
+name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
+name = "constant_time_eq"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
+
+[[package]]
name = "cookie"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -467,6 +574,43 @@ dependencies = [
]
[[package]]
+name = "ct-codecs"
+version = "1.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b10589d1a5e400d61f9f38f12f884cfd080ff345de8f17efda36fe0e4a02aa8"
+
+[[package]]
+name = "curl"
+version = "0.4.49"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79fc3b6dd0b87ba36e565715bf9a2ced221311db47bd18011676f24a6066edbc"
+dependencies = [
+ "curl-sys",
+ "libc",
+ "openssl-probe",
+ "openssl-sys",
+ "schannel",
+ "socket2 0.6.1",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "curl-sys"
+version = "0.4.83+curl-8.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5830daf304027db10c82632a464879d46a3f7c4ba17a31592657ad16c719b483"
+dependencies = [
+ "cc",
+ "libc",
+ "libnghttp2-sys",
+ "libz-sys",
+ "openssl-sys",
+ "pkg-config",
+ "vcpkg",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
name = "darling"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -487,7 +631,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim",
- "syn",
+ "syn 2.0.98",
]
[[package]]
@@ -498,7 +642,17 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core",
"quote",
- "syn",
+ "syn 2.0.98",
+]
+
+[[package]]
+name = "der"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79b71cca7d95d7681a4b3b9cdf63c8dbc3730d0584c2c74e31416d64a90493f4"
+dependencies = [
+ "const-oid 0.6.2",
+ "der_derive",
]
[[package]]
@@ -507,12 +661,24 @@ version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0"
dependencies = [
- "const-oid",
+ "const-oid 0.9.6",
"pem-rfc7468",
"zeroize",
]
[[package]]
+name = "der_derive"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8aed3b3c608dc56cf36c45fe979d04eda51242e6703d8d0bb03426ef7c41db6a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+ "synstructure 0.12.6",
+]
+
+[[package]]
name = "deranged"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -535,7 +701,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
- "const-oid",
+ "const-oid 0.9.6",
"crypto-common",
"subtle",
]
@@ -569,7 +735,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.98",
]
[[package]]
@@ -590,7 +756,7 @@ version = "0.16.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
- "der",
+ "der 0.7.9",
"digest",
"elliptic-curve",
"rfc6979",
@@ -599,6 +765,34 @@ dependencies = [
]
[[package]]
+name = "ece"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2ea1d2f2cc974957a4e2575d8e5bb494549bab66338d6320c2789abcfff5746"
+dependencies = [
+ "base64 0.21.7",
+ "byteorder",
+ "hex",
+ "hkdf",
+ "lazy_static",
+ "once_cell",
+ "openssl",
+ "serde",
+ "sha2",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "ed25519-compact"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9b3460f44bea8cd47f45a0c70892f1eff856d97cd55358b2f73f663789f6190"
+dependencies = [
+ "ct-codecs",
+ "getrandom 0.2.15",
+]
+
+[[package]]
name = "either"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -619,6 +813,7 @@ dependencies = [
"ff",
"generic-array",
"group",
+ "hkdf",
"pem-rfc7468",
"pkcs8",
"rand_core",
@@ -628,6 +823,15 @@ dependencies = [
]
[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -656,6 +860,12 @@ dependencies = [
[[package]]
name = "event-listener"
+version = "2.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
+
+[[package]]
+name = "event-listener"
version = "5.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae"
@@ -690,6 +900,15 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be"
+dependencies = [
+ "instant",
+]
+
+[[package]]
+name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
@@ -728,6 +947,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f"
[[package]]
+name = "foreign-types"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
+dependencies = [
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
+
+[[package]]
name = "form_urlencoded"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -796,6 +1030,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
+name = "futures-lite"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce"
+dependencies = [
+ "fastrand 1.9.0",
+ "futures-core",
+ "futures-io",
+ "memchr",
+ "parking",
+ "pin-project-lite",
+ "waker-fn",
+]
+
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand 2.3.0",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
name = "futures-macro"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -803,7 +1065,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.98",
]
[[package]]
@@ -854,8 +1116,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
dependencies = [
"cfg-if",
+ "js-sys",
"libc",
"wasi 0.11.0+wasi-snapshot-preview1",
+ "wasm-bindgen",
]
[[package]]
@@ -940,7 +1204,7 @@ dependencies = [
"base64 0.21.7",
"bytes",
"headers-core",
- "http",
+ "http 1.2.0",
"httpdate",
"mime",
"sha1",
@@ -952,7 +1216,7 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4"
dependencies = [
- "http",
+ "http 1.2.0",
]
[[package]]
@@ -992,6 +1256,30 @@ dependencies = [
]
[[package]]
+name = "hmac-sha1-compact"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18492c9f6f9a560e0d346369b665ad2bdbc89fa9bceca75796584e79042694c3"
+
+[[package]]
+name = "hmac-sha256"
+version = "1.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad6880c8d4a9ebf39c6e8b77007ce223f646a4d21ce29d99f70cb16420545425"
+dependencies = [
+ "digest",
+]
+
+[[package]]
+name = "hmac-sha512"
+version = "1.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e89e8d20b3799fa526152a5301a771eaaad80857f83e01b23216ceaafb2d9280"
+dependencies = [
+ "digest",
+]
+
+[[package]]
name = "home"
version = "0.5.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1002,6 +1290,17 @@ dependencies = [
[[package]]
name = "http"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
+dependencies = [
+ "bytes",
+ "fnv",
+ "itoa",
+]
+
+[[package]]
+name = "http"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea"
@@ -1018,7 +1317,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
- "http",
+ "http 1.2.0",
]
[[package]]
@@ -1029,7 +1328,7 @@ checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f"
dependencies = [
"bytes",
"futures-util",
- "http",
+ "http 1.2.0",
"http-body",
"pin-project-lite",
]
@@ -1055,7 +1354,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-util",
- "http",
+ "http 1.2.0",
"http-body",
"httparse",
"httpdate",
@@ -1073,7 +1372,7 @@ checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4"
dependencies = [
"bytes",
"futures-util",
- "http",
+ "http 1.2.0",
"http-body",
"hyper",
"pin-project-lite",
@@ -1219,7 +1518,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.98",
]
[[package]]
@@ -1272,12 +1571,48 @@ dependencies = [
]
[[package]]
+name = "instant"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]]
+name = "isahc"
+version = "1.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "334e04b4d781f436dc315cb1e7515bd96826426345d498149e4bde36b67f8ee9"
+dependencies = [
+ "async-channel",
+ "castaway",
+ "crossbeam-utils",
+ "curl",
+ "curl-sys",
+ "encoding_rs",
+ "event-listener 2.5.3",
+ "futures-lite 1.13.0",
+ "http 0.2.12",
+ "log",
+ "mime",
+ "once_cell",
+ "polling",
+ "slab",
+ "sluice",
+ "tracing",
+ "tracing-futures",
+ "url",
+ "waker-fn",
+]
+
+[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1303,6 +1638,46 @@ dependencies = [
]
[[package]]
+name = "jwt-simple"
+version = "0.12.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "731011e9647a71ff4f8474176ff6ce6e0d2de87a0173f15613af3a84c3e3401a"
+dependencies = [
+ "anyhow",
+ "binstring",
+ "blake2b_simd",
+ "coarsetime",
+ "ct-codecs",
+ "ed25519-compact",
+ "hmac-sha1-compact",
+ "hmac-sha256",
+ "hmac-sha512",
+ "k256",
+ "p256",
+ "p384",
+ "rand",
+ "serde",
+ "serde_json",
+ "superboring",
+ "thiserror 2.0.17",
+ "zeroize",
+]
+
+[[package]]
+name = "k256"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
+dependencies = [
+ "cfg-if",
+ "ecdsa",
+ "elliptic-curve",
+ "once_cell",
+ "sha2",
+ "signature",
+]
+
+[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1324,12 +1699,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa"
[[package]]
+name = "libnghttp2-sys"
+version = "0.1.11+1.64.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b6c24e48a7167cffa7119da39d577fa482e66c688a4aac016bee862e1a713c4"
+dependencies = [
+ "cc",
+ "libc",
+]
+
+[[package]]
name = "libredox"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
dependencies = [
- "bitflags",
+ "bitflags 2.8.0",
"libc",
]
@@ -1345,6 +1730,18 @@ dependencies = [
]
[[package]]
+name = "libz-sys"
+version = "1.1.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b70e7a7df205e92a1a4cd9aaae7898dac0aa555503cc0a649494d0d60e7651d"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1426,7 +1823,7 @@ version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
- "bitflags",
+ "bitflags 2.8.0",
"cfg-if",
"cfg_aliases",
"libc",
@@ -1501,6 +1898,50 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e"
[[package]]
+name = "openssl"
+version = "0.10.74"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654"
+dependencies = [
+ "bitflags 2.8.0",
+ "cfg-if",
+ "foreign-types",
+ "libc",
+ "once_cell",
+ "openssl-macros",
+ "openssl-sys",
+]
+
+[[package]]
+name = "openssl-macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.98",
+]
+
+[[package]]
+name = "openssl-probe"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
+
+[[package]]
+name = "openssl-sys"
+version = "0.9.110"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1519,6 +1960,18 @@ dependencies = [
]
[[package]]
+name = "p384"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "sha2",
+]
+
+[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1559,6 +2012,27 @@ dependencies = [
]
[[package]]
+name = "pem"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd56cbd21fea48d0c440b41cd69c589faacade08c992d9a54e471b79d0fd13eb"
+dependencies = [
+ "base64 0.13.1",
+ "once_cell",
+ "regex",
+]
+
+[[package]]
+name = "pem"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
+dependencies = [
+ "base64 0.22.1",
+ "serde_core",
+]
+
+[[package]]
name = "pem-rfc7468"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1601,7 +2075,7 @@ dependencies = [
"serde_json",
"serde_with",
"sqlx",
- "thiserror",
+ "thiserror 2.0.17",
"tokio",
"tokio-stream",
"tower",
@@ -1610,6 +2084,7 @@ dependencies = [
"unicode-segmentation",
"unix_path",
"uuid",
+ "web-push",
]
[[package]]
@@ -1629,7 +2104,7 @@ checksum = "f6e859e6e5bd50440ab63c47e3ebabc90f26251f7c73c3d3e837b74a1cc3fa67"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.98",
]
[[package]]
@@ -1650,7 +2125,7 @@ version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
dependencies = [
- "der",
+ "der 0.7.9",
"pkcs8",
"spki",
]
@@ -1661,7 +2136,7 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
- "der",
+ "der 0.7.9",
"spki",
]
@@ -1672,6 +2147,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2"
[[package]]
+name = "polling"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce"
+dependencies = [
+ "autocfg",
+ "bitflags 1.3.2",
+ "cfg-if",
+ "concurrent-queue",
+ "libc",
+ "log",
+ "pin-project-lite",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1749,7 +2240,7 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834"
dependencies = [
- "bitflags",
+ "bitflags 2.8.0",
]
[[package]]
@@ -1760,7 +2251,7 @@ checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b"
dependencies = [
"getrandom 0.2.15",
"libredox",
- "thiserror",
+ "thiserror 2.0.17",
]
[[package]]
@@ -1780,10 +2271,39 @@ checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.98",
]
[[package]]
+name = "regex"
+version = "1.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
+
+[[package]]
name = "rfc6979"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1799,7 +2319,7 @@ version = "0.9.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47c75d7c5c6b673e58bf54d8544a9f432e3a925b0e80f7cd3602ab5c50c55519"
dependencies = [
- "const-oid",
+ "const-oid 0.9.6",
"digest",
"num-bigint-dig",
"num-integer",
@@ -1807,6 +2327,7 @@ dependencies = [
"pkcs1",
"pkcs8",
"rand_core",
+ "sha2",
"signature",
"spki",
"subtle",
@@ -1819,7 +2340,7 @@ version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
dependencies = [
- "bitflags",
+ "bitflags 2.8.0",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink 0.9.1",
@@ -1848,7 +2369,7 @@ dependencies = [
"quote",
"rust-embed-utils",
"shellexpand",
- "syn",
+ "syn 2.0.98",
"walkdir",
]
@@ -1874,7 +2395,7 @@ version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
- "bitflags",
+ "bitflags 2.8.0",
"errno",
"libc",
"linux-raw-sys",
@@ -1903,6 +2424,15 @@ dependencies = [
]
[[package]]
+name = "schannel"
+version = "0.1.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
name = "schemars"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1939,7 +2469,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
"base16ct",
- "der",
+ "der 0.7.9",
"generic-array",
"pkcs8",
"subtle",
@@ -1947,23 +2477,44 @@ dependencies = [
]
[[package]]
+name = "sec1_decode"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6326ddc956378a0739200b2c30892dccaf198992dfd7323274690b9e188af23"
+dependencies = [
+ "der 0.4.5",
+ "pem 0.8.3",
+ "thiserror 1.0.69",
+]
+
+[[package]]
name = "serde"
-version = "1.0.217"
+version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
-version = "1.0.217"
+version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.98",
]
[[package]]
@@ -1981,14 +2532,15 @@ dependencies = [
[[package]]
name = "serde_json"
-version = "1.0.138"
+version = "1.0.145"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d434192e7da787e94a6ea7e9670b26a036d0ca41e0b7efb2676dd32bae872949"
+checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
+ "serde_core",
]
[[package]]
@@ -2042,7 +2594,7 @@ dependencies = [
"darling",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.98",
]
[[package]]
@@ -2102,6 +2654,17 @@ dependencies = [
]
[[package]]
+name = "sluice"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d7400c0eff44aa2fcb5e31a5f24ba9716ed90138769e4977a2ba6014ae63eb5"
+dependencies = [
+ "async-channel",
+ "futures-core",
+ "futures-io",
+]
+
+[[package]]
name = "smallvec"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2121,6 +2684,16 @@ dependencies = [
]
[[package]]
+name = "socket2"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881"
+dependencies = [
+ "libc",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2136,7 +2709,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
- "der",
+ "der 0.7.9",
]
[[package]]
@@ -2163,7 +2736,7 @@ dependencies = [
"crc",
"crossbeam-queue",
"either",
- "event-listener",
+ "event-listener 5.4.0",
"futures-core",
"futures-intrusive",
"futures-io",
@@ -2179,7 +2752,7 @@ dependencies = [
"serde_json",
"sha2",
"smallvec",
- "thiserror",
+ "thiserror 2.0.17",
"tokio",
"tokio-stream",
"tracing",
@@ -2196,7 +2769,7 @@ dependencies = [
"quote",
"sqlx-core",
"sqlx-macros-core",
- "syn",
+ "syn 2.0.98",
]
[[package]]
@@ -2219,7 +2792,7 @@ dependencies = [
"sqlx-mysql",
"sqlx-postgres",
"sqlx-sqlite",
- "syn",
+ "syn 2.0.98",
"tempfile",
"tokio",
"url",
@@ -2233,7 +2806,7 @@ checksum = "4560278f0e00ce64938540546f59f590d60beee33fffbd3b9cd47851e5fff233"
dependencies = [
"atoi",
"base64 0.22.1",
- "bitflags",
+ "bitflags 2.8.0",
"byteorder",
"bytes",
"chrono",
@@ -2263,7 +2836,7 @@ dependencies = [
"smallvec",
"sqlx-core",
"stringprep",
- "thiserror",
+ "thiserror 2.0.17",
"tracing",
"whoami",
]
@@ -2276,7 +2849,7 @@ checksum = "c5b98a57f363ed6764d5b3a12bfedf62f07aa16e1856a7ddc2a0bb190a959613"
dependencies = [
"atoi",
"base64 0.22.1",
- "bitflags",
+ "bitflags 2.8.0",
"byteorder",
"chrono",
"crc",
@@ -2301,7 +2874,7 @@ dependencies = [
"smallvec",
"sqlx-core",
"stringprep",
- "thiserror",
+ "thiserror 2.0.17",
"tracing",
"whoami",
]
@@ -2360,6 +2933,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
+name = "superboring"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "515cce34a781d7250b8a65706e0f2a5b99236ea605cb235d4baed6685820478f"
+dependencies = [
+ "getrandom 0.2.15",
+ "hmac-sha256",
+ "hmac-sha512",
+ "rand",
+ "rsa",
+]
+
+[[package]]
+name = "syn"
+version = "1.0.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
name = "syn"
version = "2.0.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2378,13 +2975,25 @@ checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "synstructure"
+version = "0.12.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+ "unicode-xid",
+]
+
+[[package]]
+name = "synstructure"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.98",
]
[[package]]
@@ -2394,7 +3003,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e5a0acb1f3f55f65cc4a866c361b2fb2a0ff6366785ae6fbb5f85df07ba230"
dependencies = [
"cfg-if",
- "fastrand",
+ "fastrand 2.3.0",
"getrandom 0.3.1",
"once_cell",
"rustix",
@@ -2403,22 +3012,42 @@ dependencies = [
[[package]]
name = "thiserror"
-version = "2.0.11"
+version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
- "thiserror-impl",
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
+dependencies = [
+ "thiserror-impl 2.0.17",
]
[[package]]
name = "thiserror-impl"
-version = "2.0.11"
+version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.98",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.98",
]
[[package]]
@@ -2488,7 +3117,7 @@ dependencies = [
"libc",
"mio",
"pin-project-lite",
- "socket2",
+ "socket2 0.5.8",
"tokio-macros",
"windows-sys 0.52.0",
]
@@ -2501,7 +3130,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.98",
]
[[package]]
@@ -2577,7 +3206,7 @@ checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.98",
]
[[package]]
@@ -2590,6 +3219,16 @@ dependencies = [
]
[[package]]
+name = "tracing-futures"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2"
+dependencies = [
+ "pin-project",
+ "tracing",
+]
+
+[[package]]
name = "typenum"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2635,6 +3274,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+[[package]]
name = "unix_path"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2700,6 +3345,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
+name = "waker-fn"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7"
+
+[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2731,6 +3382,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
+name = "wasix"
+version = "0.12.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1fbb4ef9bbca0c1170e0b00dd28abc9e3b68669821600cad1caaed606583c6d"
+dependencies = [
+ "wasi 0.11.0+wasi-snapshot-preview1",
+]
+
+[[package]]
name = "wasm-bindgen"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2752,7 +3412,7 @@ dependencies = [
"log",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.98",
"wasm-bindgen-shared",
]
@@ -2774,7 +3434,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.98",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
@@ -2789,6 +3449,28 @@ dependencies = [
]
[[package]]
+name = "web-push"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d5c305b9ee2993ab68b7744b13ef32231d83600dd879ac8183b4c76ae31d28ac"
+dependencies = [
+ "async-trait",
+ "chrono",
+ "ct-codecs",
+ "ece",
+ "futures-lite 2.6.1",
+ "http 0.2.12",
+ "isahc",
+ "jwt-simple",
+ "log",
+ "pem 3.0.6",
+ "sec1_decode",
+ "serde",
+ "serde_derive",
+ "serde_json",
+]
+
+[[package]]
name = "whoami"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2817,6 +3499,12 @@ dependencies = [
]
[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2844,6 +3532,24 @@ dependencies = [
]
[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets 0.53.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2867,7 +3573,7 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
- "windows_i686_gnullvm",
+ "windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
@@ -2875,6 +3581,23 @@ dependencies = [
]
[[package]]
+name = "windows-targets"
+version = "0.53.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+dependencies = [
+ "windows-link",
+ "windows_aarch64_gnullvm 0.53.1",
+ "windows_aarch64_msvc 0.53.1",
+ "windows_i686_gnu 0.53.1",
+ "windows_i686_gnullvm 0.53.1",
+ "windows_i686_msvc 0.53.1",
+ "windows_x86_64_gnu 0.53.1",
+ "windows_x86_64_gnullvm 0.53.1",
+ "windows_x86_64_msvc 0.53.1",
+]
+
+[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2887,6 +3610,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
+[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2899,6 +3628,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
+[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2911,12 +3646,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
+name = "windows_i686_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
+[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
+[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2929,6 +3676,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
+name = "windows_i686_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
+[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2941,6 +3694,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
+[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2953,6 +3712,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
+[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2965,12 +3730,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
+[[package]]
name = "wit-bindgen-rt"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c"
dependencies = [
- "bitflags",
+ "bitflags 2.8.0",
]
[[package]]
@@ -3005,8 +3776,8 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154"
dependencies = [
"proc-macro2",
"quote",
- "syn",
- "synstructure",
+ "syn 2.0.98",
+ "synstructure 0.13.1",
]
[[package]]
@@ -3027,7 +3798,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.98",
]
[[package]]
@@ -3047,8 +3818,8 @@ checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808"
dependencies = [
"proc-macro2",
"quote",
- "syn",
- "synstructure",
+ "syn 2.0.98",
+ "synstructure 0.13.1",
]
[[package]]
@@ -3076,5 +3847,5 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.98",
]
diff --git a/Cargo.toml b/Cargo.toml
index a3d4594..821ff6a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -56,6 +56,7 @@ unicode-normalization = "0.1.24"
unicode-segmentation = "1.12.0"
unix_path = "1.0.1"
uuid = { version = "1.13.2", features = ["v4"] }
+web-push = "0.11.0"
[dev-dependencies]
faker_rand = "0.1.1"
diff --git a/docs/api/SUMMARY.md b/docs/api/SUMMARY.md
index f51fbc7..5974ba7 100644
--- a/docs/api/SUMMARY.md
+++ b/docs/api/SUMMARY.md
@@ -7,3 +7,4 @@
- [Events](events.md)
- [Invitations](invitations.md)
- [Conversations and messages](conversations-messages.md)
+- [Push notifications](push.md)
diff --git a/docs/api/authentication.md b/docs/api/authentication.md
index 189103e..801f0e7 100644
--- a/docs/api/authentication.md
+++ b/docs/api/authentication.md
@@ -85,6 +85,8 @@ This endpoint will respond with a status of `401 Unauthorized` if the login name
Invalidates the identity token used to make the request, logging the caller out.
+This terminates any [push subscriptions](push.md#receiving-web-push-messages) associated with the token.
+
### Request
```json
@@ -103,6 +105,8 @@ The response will include a `Set-Cookie` header that clears the `identity` cooki
Changes the current user's password, and invalidates all outstanding identity tokens.
+This terminates any [push subscriptions](push.md#receiving-web-push-messages) associated with existing tokens.
+
### Authentication failure
This endpoint will respond with a status of `401 Unauthorized` if the provided identity token is not valid.
diff --git a/docs/api/basics.md b/docs/api/basics.md
index ab39570..180880e 100644
--- a/docs/api/basics.md
+++ b/docs/api/basics.md
@@ -49,3 +49,5 @@ In addition to the documented status codes for each endpoint, any endpoint may r
## Errors
When the server returns an error (any response whose status code is 400 or greater), the response body is freeform text (specifically, `text/plain`), which may be shown to the user, logged, or otherwise handled. Programmatic action should rely on the documented status codes, and not on the response body.
+
+A small number of endpoints deliver errors in other formats. These exceptions are documented with the endpoints they're relevant to.
diff --git a/docs/api/push.md b/docs/api/push.md
new file mode 100644
index 0000000..363bff3
--- /dev/null
+++ b/docs/api/push.md
@@ -0,0 +1,124 @@
+# Push Notifications
+
+```mermaid
+---
+Push lifecycle
+---
+sequenceDiagram
+ actor Andrea
+ participant API
+ participant Broker
+
+ Note over Andrea, Broker : Creating a push subscription
+
+ Andrea ->>+ API : Client Boot
+ API ->>- Andrea : Boot message with VAPID keys
+
+ Andrea ->>+ API : Subscribe to events
+
+ Andrea ->>+ Broker : New Push Subscription
+ Broker ->>- Andrea : Subscription URL
+
+ Andrea ->>+ API : Subscribe
+ API ->>- Andrea : Created
+
+ API ->>- Andrea : Disconnect
+
+ Note over Andrea, Broker : Asynchronous notification
+
+ API ->> Broker : Push Publication
+ Broker ->> Andrea : Push Delivery
+
+ Andrea ->>+ API : Resume event subscription
+ API ->>- Andrea : Disconnect
+
+ Note over Andrea, Broker : VAPID key rotation
+
+ Andrea ->>+ API : Subscribe to events
+ API -) Andrea : VAPID key changed
+
+ Andrea ->>+ Broker : Unsubscribe
+ Broker ->>- Andrea : Unsubscribed
+ Andrea ->>+ Broker : New Push Subscription
+ Broker ->>- Andrea : Subscription URL
+
+ Andrea ->>+ API : Subscribe
+ API ->>- Andrea : Created
+
+ API ->>- Andrea : Disconnect
+```
+
+Pilcrow uses [Web Push] to notify clients asynchronously of interesting events, to allow clients to disconnect from [the event stream](events.md) while keeping their users informed of updates to conversations. Clients are _not required_ to implement push subscriptions. Pilcrow's primarily channel for communicating real-time communications data to clients is the [event stream](events.md).
+
+[Web Push]: https://developer.mozilla.org/en-US/docs/Web/API/Push_API
+
+## VAPID keys
+
+Pilcrow uses a generated Voluntary Application Server Identification (VAPID) key to authenticate to push brokers. Clients do not generally need to handle VAPID authentication themselves, but, due to the design of the Web Push protocol, clients are responsible for providing Pilcrow's VAPID key to push brokers when subscribing to notifications. To make this possible, Pilcrow delivers its VAPID key to clients via the event stream. See the [VAPID key events](events.md#vapid-key-events) section for details of these events.
+
+Pilcrow rotates the VAPID key periodically, and sends all clients an event when this occurs. This immediately invalidates all existing subscriptions, as the previous VAPID key is destroyed. Clients that wish to continue receiving messages must re-subscribe using the new VAPID key when this happens, and will miss any Web Push messages sent during this interval.
+
+## Receiving Web Push messages
+
+Pilcrow sends web push messages to subscriptions. A Pilcrow user may have many subscriptions - in principle, generally one per client, but Pilcrow does not enforce any such limit. Clients create subscriptions as needed, and inform the Pilcrow server of those subscriptions to begin the flow of push messages.
+
+The specific taxonomy and structure of push messages is still under development. This API is even more experimental than the rest of Pilcrow.
+
+Pilcrow keeps track of the token used to create a web push subscription, and abandons subscriptions when that token is invalidated. This prevents the server from inadvertently sending information to a client after the user for which that information is intended has logged out, or after another user has taken over the client. Clients are responsible for re-establishing subscriptions on login if they wish to resume push messaging.
+
+## `POST /api/push/subscribe`
+
+Inform the Pilcrow server of a push subscription.
+
+### Request
+
+```json
+{
+ "subscription": {
+ "endpoint": "https://example.push.com/P1234",
+ "keys": {
+ "p256dh": "base64-encoded key",
+ "auth": "base64-encoded authentication secret"
+ }
+ }
+ "vapid": "BKILjh0SzTiHOZ5P_sfduv-iqtOg_S18nR7ePcjnDivJaOY6nOG1L3OmvjXjNnthlRDdVnawl1_owfdPCvmDt5U="
+}
+```
+
+The request must have the following fields:
+
+| Field | Type | Description |
+| :------------- | :----- | :------------------------------------------------------ |
+| `subscription` | object | The push subscription object created by the user agent. |
+| `vapid` | string | The VAPID key used to create the subscription. |
+
+The `subscription` field should be the result of calling `toJSON()` on a `PushSubscription` object in the DOM API. For details, see [the WebPush specification](https://w3c.github.io/push-api/#dom-pushsubscription-tojson) or [MDN](https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription/toJSON).
+
+The `vapid` field should be set to the key used when creating the subscription, which should in turn be obtained from the event stream.
+
+[wp-example]: https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription#sending_coding_information_to_the_server
+
+This request can be retried on any non-400 response, including any network-related errors. A duplicate subscription with the same endpoint, the same public key and the same authentication secret will be accepted by the server even if the subscription has already been created.
+
+### Success
+
+This endpoint will respond with a status of `201 Created` when successful. The response body is empty.
+
+### Duplicate endpoint
+
+If the push subscription's endpoint URL is already associated with a subscription with different keys, then Pilcrow will return a `409 Conflict` response.
+
+### Stale VAPID key
+
+If the provided `vapid` key is not the server's current VAPID key, then the client has created a subscription which is not usable. This may happen if Pilcrow rotates its key while a client is negotiating with a Web Push broker to set up a subscription using the old key. Pilcrow will respond a `400 Bad Request` response.
+
+**This response includes a JSON body**, unlike other errors, and will have a `content-type: application/json` header.
+
+The response will include the following fields:
+
+| Field | Type | Description |
+| :-------- | :----- | :------------------------------ |
+| `message` | string | A human-readable error message. |
+| `key` | string | Pilcrow's new VAPID key. |
+
+Clients should immediately destroy the push subscription they were attempting to create, and may try again using the new VAPID key (either immediately, using the key from the response, or asynchronously, using the key when it is delivered via the event stream).
diff --git a/migrations/20251009021241_push_subscriptions.sql b/migrations/20251009021241_push_subscriptions.sql
new file mode 100644
index 0000000..b42d122
--- /dev/null
+++ b/migrations/20251009021241_push_subscriptions.sql
@@ -0,0 +1,12 @@
+create table push_subscription (
+ endpoint text
+ primary key
+ not null,
+ token text
+ not null
+ references token (id),
+ p256dh text
+ not null,
+ auth text
+ not null
+);
diff --git a/src/app.rs b/src/app.rs
index 2bfabbe..e24331b 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -10,6 +10,7 @@ use crate::{
invite::app::Invites,
login::app::Logins,
message::app::Messages,
+ push::app::Push,
setup::app::Setup,
token::{self, app::Tokens},
vapid::app::Vapid,
@@ -59,6 +60,10 @@ impl App {
Messages::new(self.db.clone(), self.events.clone())
}
+ pub fn push(&self) -> Push {
+ Push::new(self.db.clone())
+ }
+
pub fn setup(&self) -> Setup {
Setup::new(self.db.clone(), self.events.clone())
}
@@ -107,6 +112,12 @@ impl FromRef<App> for Messages {
}
}
+impl FromRef<App> for Push {
+ fn from_ref(app: &App) -> Self {
+ app.push()
+ }
+}
+
impl FromRef<App> for Setup {
fn from_ref(app: &App) -> Self {
app.setup()
diff --git a/src/lib.rs b/src/lib.rs
index 6b2a83c..38e6bc5 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -20,6 +20,7 @@ mod message;
mod name;
mod normalize;
mod password;
+mod push;
mod routes;
mod setup;
#[cfg(test)]
diff --git a/src/login/app.rs b/src/login/app.rs
index a2f9636..8cc8cd0 100644
--- a/src/login/app.rs
+++ b/src/login/app.rs
@@ -6,6 +6,7 @@ use crate::{
login::{self, Login, repo::Provider as _},
name::{self, Name},
password::Password,
+ push::repo::Provider as _,
token::{Broadcaster, Event as TokenEvent, Secret, Token, repo::Provider as _},
};
@@ -76,6 +77,7 @@ impl Logins {
let mut tx = self.db.begin().await?;
tx.logins().set_password(&login, &to_hash).await?;
+ tx.push().unsubscribe_login(&login).await?;
let revoked = tx.tokens().revoke_all(&login).await?;
tx.tokens().create(&token, &secret).await?;
tx.commit().await?;
diff --git a/src/push/app.rs b/src/push/app.rs
new file mode 100644
index 0000000..358a8cc
--- /dev/null
+++ b/src/push/app.rs
@@ -0,0 +1,76 @@
+use p256::ecdsa::VerifyingKey;
+use sqlx::SqlitePool;
+use web_push::SubscriptionInfo;
+
+use super::repo::Provider as _;
+use crate::{token::extract::Identity, vapid, vapid::repo::Provider as _};
+
+pub struct Push {
+ db: SqlitePool,
+}
+
+impl Push {
+ pub const fn new(db: SqlitePool) -> Self {
+ Self { db }
+ }
+
+ pub async fn subscribe(
+ &self,
+ subscriber: &Identity,
+ subscription: &SubscriptionInfo,
+ vapid: &VerifyingKey,
+ ) -> Result<(), SubscribeError> {
+ let mut tx = self.db.begin().await?;
+
+ let current = tx.vapid().current().await?;
+ if vapid != &current.key {
+ return Err(SubscribeError::StaleVapidKey(current.key));
+ }
+
+ match tx.push().create(&subscriber.token, subscription).await {
+ Ok(()) => (),
+ Err(err) => {
+ if let Some(err) = err.as_database_error()
+ && err.is_unique_violation()
+ {
+ let current = tx
+ .push()
+ .by_endpoint(&subscriber.login, &subscription.endpoint)
+ .await?;
+ // If we already have a subscription for this endpoint, with _different_
+ // parameters, then this is a client error. They shouldn't reuse endpoint URLs,
+ // per the various RFCs.
+ //
+ // However, if we have a subscription for this endpoint with the same parameters
+ // then we accept it and silently do nothing. This may happen if, for example,
+ // the subscribe request is retried due to a network interruption where it's
+ // not clear whether the original request succeeded.
+ if &current != subscription {
+ return Err(SubscribeError::Duplicate);
+ }
+ } else {
+ return Err(SubscribeError::Database(err));
+ }
+ }
+ }
+
+ tx.commit().await?;
+
+ Ok(())
+ }
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum SubscribeError {
+ #[error(transparent)]
+ Database(#[from] sqlx::Error),
+ #[error(transparent)]
+ Vapid(#[from] vapid::repo::Error),
+ #[error("subscription created with stale VAPID key")]
+ StaleVapidKey(VerifyingKey),
+ #[error("subscription already exists for endpoint")]
+ // The endpoint URL is not included in the error, as it is a bearer credential in its own right
+ // and we want to limit its proliferation. The only intended recipient of this message is the
+ // client, which already knows the endpoint anyways and doesn't need us to tell them.
+ Duplicate,
+}
diff --git a/src/push/handlers/mod.rs b/src/push/handlers/mod.rs
new file mode 100644
index 0000000..86eeea0
--- /dev/null
+++ b/src/push/handlers/mod.rs
@@ -0,0 +1,3 @@
+mod subscribe;
+
+pub use subscribe::handler as subscribe;
diff --git a/src/push/handlers/subscribe/mod.rs b/src/push/handlers/subscribe/mod.rs
new file mode 100644
index 0000000..d142df6
--- /dev/null
+++ b/src/push/handlers/subscribe/mod.rs
@@ -0,0 +1,95 @@
+use axum::{
+ extract::{Json, State},
+ http::StatusCode,
+ response::{IntoResponse, Response},
+};
+use p256::ecdsa::VerifyingKey;
+use web_push::SubscriptionInfo;
+
+use crate::{
+ error::Internal,
+ push::{app, app::Push},
+ token::extract::Identity,
+};
+
+#[cfg(test)]
+mod test;
+
+#[derive(Clone, serde::Deserialize)]
+pub struct Request {
+ subscription: Subscription,
+ #[serde(with = "crate::vapid::ser::key")]
+ vapid: VerifyingKey,
+}
+
+// This structure is described in <https://w3c.github.io/push-api/#dom-pushsubscription-tojson>.
+#[derive(Clone, serde::Deserialize)]
+pub struct Subscription {
+ endpoint: String,
+ keys: Keys,
+}
+
+// This structure is described in <https://w3c.github.io/push-api/#dom-pushsubscription-tojson>.
+#[derive(Clone, serde::Deserialize)]
+pub struct Keys {
+ p256dh: String,
+ auth: String,
+}
+
+pub async fn handler(
+ State(push): State<Push>,
+ identity: Identity,
+ Json(request): Json<Request>,
+) -> Result<StatusCode, Error> {
+ let Request {
+ subscription,
+ vapid,
+ } = request;
+
+ push.subscribe(&identity, &subscription.into(), &vapid)
+ .await?;
+
+ Ok(StatusCode::CREATED)
+}
+
+impl From<Subscription> for SubscriptionInfo {
+ fn from(request: Subscription) -> Self {
+ let Subscription {
+ endpoint,
+ keys: Keys { p256dh, auth },
+ } = request;
+ let info = SubscriptionInfo::new(endpoint, p256dh, auth);
+ info
+ }
+}
+
+#[derive(Debug, thiserror::Error)]
+#[error(transparent)]
+pub struct Error(#[from] app::SubscribeError);
+
+impl IntoResponse for Error {
+ fn into_response(self) -> Response {
+ let Self(err) = self;
+
+ match err {
+ app::SubscribeError::StaleVapidKey(key) => {
+ let body = StaleVapidKey {
+ message: err.to_string(),
+ key,
+ };
+ (StatusCode::BAD_REQUEST, Json(body)).into_response()
+ }
+ app::SubscribeError::Duplicate => {
+ (StatusCode::CONFLICT, err.to_string()).into_response()
+ }
+ other => Internal::from(other).into_response(),
+ }
+ }
+}
+
+#[derive(serde::Serialize)]
+struct StaleVapidKey {
+ message: String,
+ #[serde(with = "crate::vapid::ser::key")]
+ key: VerifyingKey,
+}
diff --git a/src/push/handlers/subscribe/test.rs b/src/push/handlers/subscribe/test.rs
new file mode 100644
index 0000000..b72624d
--- /dev/null
+++ b/src/push/handlers/subscribe/test.rs
@@ -0,0 +1,236 @@
+use axum::{
+ extract::{Json, State},
+ http::StatusCode,
+};
+
+use crate::{
+ push::app::SubscribeError,
+ test::{fixtures, fixtures::event},
+};
+
+#[tokio::test]
+async fn accepts_new_subscription() {
+ let app = fixtures::scratch_app().await;
+ let subscriber = fixtures::identity::create(&app, &fixtures::now()).await;
+
+ // Issue a VAPID key.
+
+ app.vapid()
+ .refresh_key(&fixtures::now())
+ .await
+ .expect("refreshing the VAPID key always succeeds");
+
+ // Find out what that VAPID key is.
+
+ let boot = app.boot().snapshot().await.expect("boot always succeeds");
+ let vapid = boot
+ .events
+ .into_iter()
+ .filter_map(event::vapid)
+ .filter_map(event::vapid::changed)
+ .next_back()
+ .expect("the application will have a vapid key after a refresh");
+
+ // Create a dummy subscription with that key.
+
+ let request = super::Request {
+ subscription: super::Subscription {
+ endpoint: String::from("https://push.example.com/endpoint"),
+ keys: super::Keys {
+ p256dh: String::from("test-p256dh-value"),
+ auth: String::from("test-auth-value"),
+ },
+ },
+ vapid: vapid.key,
+ };
+ let response = super::handler(State(app.push()), subscriber, Json(request))
+ .await
+ .expect("test request will succeed on a fresh app");
+
+ // Check that the response looks as expected.
+
+ assert_eq!(StatusCode::CREATED, response);
+}
+
+#[tokio::test]
+async fn accepts_repeat_subscription() {
+ let app = fixtures::scratch_app().await;
+ let subscriber = fixtures::identity::create(&app, &fixtures::now()).await;
+
+ // Issue a VAPID key.
+
+ app.vapid()
+ .refresh_key(&fixtures::now())
+ .await
+ .expect("refreshing the VAPID key always succeeds");
+
+ // Find out what that VAPID key is.
+
+ let boot = app.boot().snapshot().await.expect("boot always succeeds");
+ let vapid = boot
+ .events
+ .into_iter()
+ .filter_map(event::vapid)
+ .filter_map(event::vapid::changed)
+ .next_back()
+ .expect("the application will have a vapid key after a refresh");
+
+ // Create a dummy subscription with that key.
+
+ let request = super::Request {
+ subscription: super::Subscription {
+ endpoint: String::from("https://push.example.com/endpoint"),
+ keys: super::Keys {
+ p256dh: String::from("test-p256dh-value"),
+ auth: String::from("test-auth-value"),
+ },
+ },
+ vapid: vapid.key,
+ };
+ let response = super::handler(State(app.push()), subscriber.clone(), Json(request.clone()))
+ .await
+ .expect("test request will succeed on a fresh app");
+
+ // Check that the response looks as expected.
+
+ assert_eq!(StatusCode::CREATED, response);
+
+ // Repeat the request
+
+ let response = super::handler(State(app.push()), subscriber, Json(request))
+ .await
+ .expect("test request will succeed twice on a fresh app");
+
+ // Check that the second response also looks as expected.
+
+ assert_eq!(StatusCode::CREATED, response);
+}
+
+#[tokio::test]
+async fn rejects_duplicate_subscription() {
+ let app = fixtures::scratch_app().await;
+ let subscriber = fixtures::identity::create(&app, &fixtures::now()).await;
+
+ // Issue a VAPID key.
+
+ app.vapid()
+ .refresh_key(&fixtures::now())
+ .await
+ .expect("refreshing the VAPID key always succeeds");
+
+ // Find out what that VAPID key is.
+
+ let boot = app.boot().snapshot().await.expect("boot always succeeds");
+ let vapid = boot
+ .events
+ .into_iter()
+ .filter_map(event::vapid)
+ .filter_map(event::vapid::changed)
+ .next_back()
+ .expect("the application will have a vapid key after a refresh");
+
+ // Create a dummy subscription with that key.
+
+ let request = super::Request {
+ subscription: super::Subscription {
+ endpoint: String::from("https://push.example.com/endpoint"),
+ keys: super::Keys {
+ p256dh: String::from("test-p256dh-value"),
+ auth: String::from("test-auth-value"),
+ },
+ },
+ vapid: vapid.key,
+ };
+ super::handler(State(app.push()), subscriber.clone(), Json(request))
+ .await
+ .expect("test request will succeed on a fresh app");
+
+ // Repeat the request with different keys
+
+ let request = super::Request {
+ subscription: super::Subscription {
+ endpoint: String::from("https://push.example.com/endpoint"),
+ keys: super::Keys {
+ p256dh: String::from("different-test-p256dh-value"),
+ auth: String::from("different-test-auth-value"),
+ },
+ },
+ vapid: vapid.key,
+ };
+ let response = super::handler(State(app.push()), subscriber, Json(request))
+ .await
+ .expect_err("request with duplicate endpoint should fail");
+
+ // Make sure we got the error we expected.
+
+ assert!(matches!(response, super::Error(SubscribeError::Duplicate)));
+}
+
+#[tokio::test]
+async fn rejects_stale_vapid_key() {
+ let app = fixtures::scratch_app().await;
+ let subscriber = fixtures::identity::create(&app, &fixtures::now()).await;
+
+ // Issue a VAPID key.
+
+ app.vapid()
+ .refresh_key(&fixtures::now())
+ .await
+ .expect("refreshing the VAPID key always succeeds");
+
+ // Find out what that VAPID key is.
+
+ let boot = app.boot().snapshot().await.expect("boot always succeeds");
+ let vapid = boot
+ .events
+ .into_iter()
+ .filter_map(event::vapid)
+ .filter_map(event::vapid::changed)
+ .next_back()
+ .expect("the application will have a vapid key after a refresh");
+
+ // Change the VAPID key.
+
+ app.vapid()
+ .rotate_key()
+ .await
+ .expect("key rotation always succeeds");
+ app.vapid()
+ .refresh_key(&fixtures::now())
+ .await
+ .expect("refreshing the VAPID key always succeeds");
+
+ // Find out what the new VAPID key is.
+
+ let boot = app.boot().snapshot().await.expect("boot always succeeds");
+ let fresh_vapid = boot
+ .events
+ .into_iter()
+ .filter_map(event::vapid)
+ .filter_map(event::vapid::changed)
+ .next_back()
+ .expect("the application will have a vapid key after a refresh");
+
+ // Create a dummy subscription with the original key.
+
+ let request = super::Request {
+ subscription: super::Subscription {
+ endpoint: String::from("https://push.example.com/endpoint"),
+ keys: super::Keys {
+ p256dh: String::from("test-p256dh-value"),
+ auth: String::from("test-auth-value"),
+ },
+ },
+ vapid: vapid.key,
+ };
+ let response = super::handler(State(app.push()), subscriber, Json(request))
+ .await
+ .expect_err("test request has a stale vapid key");
+
+ // Check that the response looks as expected.
+
+ assert!(matches!(
+ response,
+ super::Error(SubscribeError::StaleVapidKey(key)) if key == fresh_vapid.key
+ ));
+}
diff --git a/src/push/mod.rs b/src/push/mod.rs
new file mode 100644
index 0000000..1394ea4
--- /dev/null
+++ b/src/push/mod.rs
@@ -0,0 +1,3 @@
+pub mod app;
+pub mod handlers;
+pub mod repo;
diff --git a/src/push/repo.rs b/src/push/repo.rs
new file mode 100644
index 0000000..6c18c6e
--- /dev/null
+++ b/src/push/repo.rs
@@ -0,0 +1,114 @@
+use sqlx::{Sqlite, SqliteConnection, Transaction};
+use web_push::SubscriptionInfo;
+
+use crate::{login::Login, token::Token};
+
+pub trait Provider {
+ fn push(&mut self) -> Push<'_>;
+}
+
+impl Provider for Transaction<'_, Sqlite> {
+ fn push(&mut self) -> Push<'_> {
+ Push(self)
+ }
+}
+
+pub struct Push<'t>(&'t mut SqliteConnection);
+
+impl Push<'_> {
+ pub async fn create(
+ &mut self,
+ token: &Token,
+ subscription: &SubscriptionInfo,
+ ) -> Result<(), sqlx::Error> {
+ sqlx::query!(
+ r#"
+ insert into push_subscription (token, endpoint, p256dh, auth)
+ values ($1, $2, $3, $4)
+ "#,
+ token.id,
+ subscription.endpoint,
+ subscription.keys.p256dh,
+ subscription.keys.auth,
+ )
+ .execute(&mut *self.0)
+ .await?;
+
+ Ok(())
+ }
+
+ pub async fn by_endpoint(
+ &mut self,
+ subscriber: &Login,
+ endpoint: &str,
+ ) -> Result<SubscriptionInfo, sqlx::Error> {
+ let row = sqlx::query!(
+ r#"
+ select
+ subscription.endpoint,
+ subscription.p256dh,
+ subscription.auth
+ from push_subscription as subscription
+ join token on subscription.token = token.id
+ join login as subscriber on token.login = subscriber.id
+ where subscriber.id = $1
+ and subscription.endpoint = $2
+ "#,
+ subscriber.id,
+ endpoint,
+ )
+ .fetch_one(&mut *self.0)
+ .await?;
+
+ let info = SubscriptionInfo::new(row.endpoint, row.p256dh, row.auth);
+
+ Ok(info)
+ }
+
+ pub async fn unsubscribe_token(&mut self, token: &Token) -> Result<(), sqlx::Error> {
+ sqlx::query!(
+ r#"
+ delete from push_subscription
+ where token = $1
+ "#,
+ token.id,
+ )
+ .execute(&mut *self.0)
+ .await?;
+
+ Ok(())
+ }
+
+ pub async fn unsubscribe_login(&mut self, login: &Login) -> Result<(), sqlx::Error> {
+ sqlx::query!(
+ r#"
+ with tokens as (
+ select id from token
+ where login = $1
+ )
+ delete from push_subscription
+ where token in tokens
+ "#,
+ login.id,
+ )
+ .execute(&mut *self.0)
+ .await?;
+
+ Ok(())
+ }
+
+ // Unsubscribe logic for token expiry lives in the `tokens` repository, for maintenance reasons.
+
+ pub async fn clear(&mut self) -> Result<(), sqlx::Error> {
+ // We assume that _all_ stored subscriptions are for a VAPID key we're about to delete.
+ sqlx::query!(
+ r#"
+ delete from push_subscription
+ "#,
+ )
+ .execute(&mut *self.0)
+ .await?;
+
+ Ok(())
+ }
+}
diff --git a/src/routes.rs b/src/routes.rs
index 2979abe..00d9d3e 100644
--- a/src/routes.rs
+++ b/src/routes.rs
@@ -5,7 +5,7 @@ use axum::{
};
use crate::{
- app::App, boot, conversation, event, expire, invite, login, message, setup, ui, vapid,
+ app::App, boot, conversation, event, expire, invite, login, message, push, setup, ui, vapid,
};
pub fn routes(app: &App) -> Router<App> {
@@ -46,6 +46,7 @@ pub fn routes(app: &App) -> Router<App> {
.route("/api/invite/{invite}", get(invite::handlers::get))
.route("/api/invite/{invite}", post(invite::handlers::accept))
.route("/api/messages/{message}", delete(message::handlers::delete))
+ .route("/api/push/subscribe", post(push::handlers::subscribe))
.route("/api/password", post(login::handlers::change_password))
// Run expiry whenever someone accesses the API. This was previously a blanket middleware
// affecting the whole service, but loading the client makes a several requests before the
diff --git a/src/token/app.rs b/src/token/app.rs
index 332473d..4a08877 100644
--- a/src/token/app.rs
+++ b/src/token/app.rs
@@ -10,7 +10,7 @@ use super::{
extract::Identity,
repo::{self, Provider as _},
};
-use crate::{clock::DateTime, db::NotFound as _, name};
+use crate::{clock::DateTime, db::NotFound as _, name, push::repo::Provider as _};
pub struct Tokens {
db: SqlitePool,
@@ -112,6 +112,7 @@ impl Tokens {
pub async fn logout(&self, token: &Token) -> Result<(), ValidateError> {
let mut tx = self.db.begin().await?;
+ tx.push().unsubscribe_token(token).await?;
tx.tokens().revoke(token).await?;
tx.commit().await?;
diff --git a/src/token/repo/token.rs b/src/token/repo/token.rs
index 52a3987..33c33af 100644
--- a/src/token/repo/token.rs
+++ b/src/token/repo/token.rs
@@ -89,6 +89,23 @@ impl Tokens<'_> {
// Expire and delete all tokens that haven't been used more recently than
// `expire_at`.
pub async fn expire(&mut self, expire_at: &DateTime) -> Result<Vec<Id>, sqlx::Error> {
+ // This lives here, rather than in the `push` repository, to ensure that the criteria for
+ // stale tokens don't drift apart between the two queries. That would be a larger risk if
+ // the queries lived in very separate parts of the codebase.
+ sqlx::query!(
+ r#"
+ with stale_tokens as (
+ select id from token
+ where last_used_at < $1
+ )
+ delete from push_subscription
+ where token in stale_tokens
+ "#,
+ expire_at,
+ )
+ .execute(&mut *self.0)
+ .await?;
+
let tokens = sqlx::query_scalar!(
r#"
delete
diff --git a/src/vapid/app.rs b/src/vapid/app.rs
index 61523d5..7d872ed 100644
--- a/src/vapid/app.rs
+++ b/src/vapid/app.rs
@@ -6,6 +6,7 @@ use crate::{
clock::DateTime,
db::NotFound as _,
event::{Broadcaster, Sequence, repo::Provider},
+ push::repo::Provider as _,
};
pub struct Vapid {
@@ -60,6 +61,10 @@ impl Vapid {
let changed_at = tx.sequence().next(ensure_at).await?;
let (key, secret) = key.rotate(&changed_at);
+ // This will delete _all_ stored subscriptions. This is fine; they're all for the
+ // current VAPID key, and we won't be able to use them anyways once the key is rotated.
+ // We have no way to inform the push broker services of that, unfortunately.
+ tx.push().clear().await?;
tx.vapid().clear().await?;
tx.vapid().store_signing_key(&secret).await?;
diff --git a/src/vapid/event.rs b/src/vapid/event.rs
index af70ac2..cf3be77 100644
--- a/src/vapid/event.rs
+++ b/src/vapid/event.rs
@@ -1,6 +1,4 @@
-use base64::{Engine, engine::general_purpose::URL_SAFE};
use p256::ecdsa::VerifyingKey;
-use serde::Serialize;
use crate::event::{Instant, Sequenced};
@@ -22,7 +20,7 @@ impl Sequenced for Event {
pub struct Changed {
#[serde(flatten)]
pub instant: Instant,
- #[serde(serialize_with = "as_vapid_key")]
+ #[serde(with = "crate::vapid::ser::key")]
pub key: VerifyingKey,
}
@@ -37,12 +35,3 @@ impl Sequenced for Changed {
self.instant
}
}
-
-fn as_vapid_key<S>(key: &VerifyingKey, serializer: S) -> Result<S::Ok, S::Error>
-where
- S: serde::Serializer,
-{
- let key = key.to_sec1_bytes();
- let key = URL_SAFE.encode(key);
- key.serialize(serializer)
-}
diff --git a/src/vapid/middleware.rs b/src/vapid/middleware.rs
index 02951ba..3129aa7 100644
--- a/src/vapid/middleware.rs
+++ b/src/vapid/middleware.rs
@@ -4,14 +4,14 @@ use axum::{
response::Response,
};
-use crate::{app::App, clock::RequestedAt, error::Internal};
+use crate::{clock::RequestedAt, error::Internal, vapid::app::Vapid};
pub async fn middleware(
- State(app): State<App>,
+ State(vapid): State<Vapid>,
RequestedAt(now): RequestedAt,
request: Request,
next: Next,
) -> Result<Response, Internal> {
- app.vapid().refresh_key(&now).await?;
+ vapid.refresh_key(&now).await?;
Ok(next.run(request).await)
}
diff --git a/src/vapid/mod.rs b/src/vapid/mod.rs
index 9798654..364f602 100644
--- a/src/vapid/mod.rs
+++ b/src/vapid/mod.rs
@@ -3,6 +3,7 @@ pub mod event;
mod history;
mod middleware;
pub mod repo;
+pub mod ser;
pub use event::Event;
pub use history::History;
diff --git a/src/vapid/ser.rs b/src/vapid/ser.rs
new file mode 100644
index 0000000..02c77e1
--- /dev/null
+++ b/src/vapid/ser.rs
@@ -0,0 +1,63 @@
+pub mod key {
+ use std::fmt;
+
+ use base64::{Engine as _, engine::general_purpose::URL_SAFE};
+ use p256::ecdsa::VerifyingKey;
+ use serde::{Deserializer, Serialize as _, de};
+
+ // This serialization - to a URL-safe base-64-encoded string and back - is based on my best
+ // understanding of RFC 8292 and the corresponding browser APIs. Particularly, it's based on
+ // section 3.2:
+ //
+ // > The "k" parameter includes an ECDSA public key [FIPS186] in uncompressed form [X9.62] that
+ // > is encoded using base64url encoding [RFC7515].
+ //
+ // <https://datatracker.ietf.org/doc/html/rfc8292#section-3.2>
+ //
+ // I believe this is also supported by MDN's explanation:
+ //
+ // > `applicationServerKey`
+ // >
+ // > A Base64-encoded string or ArrayBuffer containing an ECDSA P-256 public key that the push
+ // > server will use to authenticate your application server. If specified, all messages from
+ // > your application server must use the VAPID authentication scheme, and include a JWT signed
+ // > with the corresponding private key. This key IS NOT the same ECDH key that you use to
+ // > encrypt the data. For more information, see "Using VAPID with WebPush".
+ //
+ // <https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe#applicationserverkey>
+
+ pub fn serialize<S>(key: &VerifyingKey, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ let key = key.to_sec1_bytes();
+ let key = URL_SAFE.encode(key);
+ key.serialize(serializer)
+ }
+
+ pub fn deserialize<'de, D>(deserializer: D) -> Result<VerifyingKey, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ deserializer.deserialize_str(Visitor)
+ }
+
+ struct Visitor;
+ impl de::Visitor<'_> for Visitor {
+ type Value = VerifyingKey;
+
+ fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
+ formatter.write_str("a string containing a VAPID key")
+ }
+
+ fn visit_str<E>(self, key: &str) -> Result<Self::Value, E>
+ where
+ E: de::Error,
+ {
+ let key = URL_SAFE.decode(key).map_err(E::custom)?;
+ let key = VerifyingKey::from_sec1_bytes(&key).map_err(E::custom)?;
+
+ Ok(key)
+ }
+ }
+}
diff --git a/ui/lib/apiServer.js b/ui/lib/apiServer.js
index ac707a5..f55f271 100644
--- a/ui/lib/apiServer.js
+++ b/ui/lib/apiServer.js
@@ -55,6 +55,10 @@ export async function acceptInvite(inviteId, name, password) {
.catch(responseError);
}
+export async function createPushSubscription(subscription, vapid) {
+ return apiServer.post('/push/subscribe', { subscription, vapid }).catch(responseError);
+}
+
export function subscribeToEvents(resumePoint) {
const eventsUrl = apiServer.getUri({
url: '/events',
diff --git a/ui/lib/components/PushSubscription.svelte b/ui/lib/components/PushSubscription.svelte
new file mode 100644
index 0000000..a85cbb3
--- /dev/null
+++ b/ui/lib/components/PushSubscription.svelte
@@ -0,0 +1,27 @@
+<script>
+ let { vapid, subscription, subscribe = async () => null } = $props();
+ let pending = $state(false);
+
+ function onsubmit(callback) {
+ return async (evt) => {
+ evt.preventDefault();
+
+ pending = true;
+ try {
+ await callback();
+ } finally {
+ pending = false;
+ }
+ };
+ }
+</script>
+
+{#if vapid !== null}
+ {#if subscription === null}
+ <form class="form" onsubmit={onsubmit(subscribe)}>
+ <button disabled={pending} type="submit">create push subscription</button>
+ </form>
+ {/if}
+{:else}
+ Waiting for VAPID key…
+{/if}
diff --git a/ui/lib/session.svelte.js b/ui/lib/session.svelte.js
index c415d0c..cd41aa4 100644
--- a/ui/lib/session.svelte.js
+++ b/ui/lib/session.svelte.js
@@ -5,6 +5,7 @@ import { goto } from '$app/navigation';
import * as api from './apiServer.js';
import * as r from './state/remote/state.svelte.js';
import * as l from './state/local/conversations.svelte.js';
+import * as p from './state/local/push.svelte.js';
import { Watchdog } from './watchdog.js';
import { DateTime } from 'luxon';
@@ -51,6 +52,7 @@ class Message {
class Session {
remote = $state();
local = $state();
+ push = $state();
currentUser = $derived(this.remote.currentUser);
users = $derived(this.remote.users.all);
messages = $derived(
@@ -62,7 +64,7 @@ class Session {
),
);
- static boot({ login, resume_point, heartbeat, events }) {
+ static async boot({ login, resume_point, heartbeat, events }) {
const remote = r.State.boot({
currentUser: login,
resumePoint: resume_point,
@@ -70,22 +72,25 @@ class Session {
events,
});
const local = l.Conversations.fromLocalStorage();
- return new Session(remote, local);
+ const push = await p.Push.boot(events);
+ return new Session(remote, local, push);
}
- reboot({ login, resume_point, heartbeat, events }) {
+ async reboot({ login, resume_point, heartbeat, events }) {
this.remote = r.State.boot({
currentUser: login,
resumePoint: resume_point,
heartbeat,
events,
});
+ this.push = await p.Push.boot(events);
}
- constructor(remote, local) {
+ constructor(remote, local, push) {
this.watchdog = new Watchdog(this.watchdogExpired.bind(this));
this.remote = remote;
this.local = local;
+ this.push = push;
}
begin() {
@@ -107,6 +112,7 @@ class Session {
onMessage(message) {
const event = JSON.parse(message.data);
this.remote.onEvent(event);
+ this.push.onEvent(event);
this.local.retainConversations(this.remote.conversations.all);
this.watchdog.reset(this.heartbeatMillis());
}
@@ -127,7 +133,7 @@ class Session {
// which the session may have been abandoned.
if (!this.active()) return;
- this.reboot(response);
+ await this.reboot(response);
this.begin();
}
}
@@ -139,6 +145,7 @@ async function bootOrNavigate(navigateTo) {
} catch (err) {
switch (true) {
case err instanceof api.LoggedOut:
+ await this.push.unsubscribe();
await navigateTo('/login');
break;
case err instanceof api.SetupRequired:
@@ -152,5 +159,5 @@ async function bootOrNavigate(navigateTo) {
export async function boot() {
const response = await bootOrNavigate(async (url) => redirect(307, url));
- return Session.boot(response);
+ return await Session.boot(response);
}
diff --git a/ui/lib/state/local/push.svelte.js b/ui/lib/state/local/push.svelte.js
new file mode 100644
index 0000000..82846b1
--- /dev/null
+++ b/ui/lib/state/local/push.svelte.js
@@ -0,0 +1,141 @@
+import * as api from '$lib/apiServer.js';
+
+// In a few places in this module, I've used suffix to identify which type we're working with:
+// * ...Base64 is a string containing a URL-safe base64 value;
+// * ...Buffer is an ArrayBuffer holding untyped bytes; and
+// * ...Bytes is a Uint8Array holding typed bytes.
+// Working with byte streams in JS is _fun_.
+
+export class Push {
+ static async boot(events) {
+ const serviceWorker = await navigator.serviceWorker.ready;
+ const pushManager = serviceWorker.pushManager;
+ const subscription = await serviceWorker.pushManager.getSubscription();
+
+ const push = new Push(pushManager, subscription);
+
+ for (const event of events) {
+ push.onEvent(event);
+ }
+
+ return push;
+ }
+
+ vapidKey = $state(null);
+ subscription = $state(null);
+
+ constructor(pushManager, subscription) {
+ this.pushManager = pushManager;
+ this.subscription = subscription;
+ }
+
+ async hasPermission() {
+ const vapidKeyBuffer = this.vapidKey;
+ const pushOptions = {
+ userVisibleOnly: true,
+ applicationServerKey: vapidKeyBuffer,
+ };
+
+ const state = await this.pushManager.permissionState(pushOptions);
+ return state === 'granted';
+ }
+
+ async subscribe() {
+ const vapidKeyBuffer = this.vapidKey;
+ const pushOptions = {
+ userVisibleOnly: true,
+ applicationServerKey: vapidKeyBuffer,
+ };
+ this.subscription = await this.pushManager.subscribe(pushOptions);
+
+ const subscriptionJSON = this.subscription.toJSON();
+ const vapidKeyBytes = new Uint8Array(vapidKeyBuffer);
+ const vapidKeyBase64 = vapidKeyBytes.toBase64({ alphabet: 'base64url' });
+ await api.createPushSubscription(subscriptionJSON, vapidKeyBase64);
+ }
+
+ async resubscribe() {
+ if (this.subscription !== null) {
+ // If we have a subscription but it's not for the current VAPID key, then the VAPID key has
+ // been rotated and we need to replace the subscription. The server cannot deliver messages to
+ // subscriptions associated with the old VAPID key after rotation.
+
+ // Per spec, the `options.applicationServerKey` field is either null or an ArrayBuffer,
+ // regardless of the representation passed into `subscribe` to create the subscription:
+ //
+ // <https://w3c.github.io/push-api/#pushsubscriptionoptions-interface>
+ if (!equalArrayBuffers(this.subscription.options.applicationServerKey, this.vapidKey)) {
+ // We have a subscription, and the server key has rotated, so resubscribe. Destroy the old
+ // subscription first - some UAs (Firefox) want subscriptions within an origin to share a
+ // consistent VAPID key, and we're explicitly changing the VAPID key here.
+ await this.unsubscribe();
+ await this.subscribe();
+ }
+ } else if (await this.hasPermission()) {
+ // If we have permission to create push subscriptions, but no push subscription, then set up
+ // a new subscription. This primarily happens after logging into Pilcrow if the user
+ // previously had a push subscription and has logged out.
+ await this.subscribe();
+ }
+ }
+
+ async unsubscribe() {
+ if (this.subscription !== null) {
+ await this.subscription.unsubscribe();
+ this.subscription = null;
+ }
+ }
+
+ onEvent(event) {
+ switch (event.type) {
+ case 'vapid':
+ return this.onVapidEvent(event);
+ }
+ }
+
+ onVapidEvent(event) {
+ switch (event.event) {
+ case 'changed':
+ return this.onVapidChanged(event);
+ }
+ }
+
+ onVapidChanged(event) {
+ const { key: vapidKeyBase64 } = event;
+
+ // For ease of use later on, parse the key into an ArrayBuffer. This is a little fiddly because
+ // of the APIs involved, but it makes comparing the key to the current subscription's key
+ // (which is also provided as an ArrayBuffer) much easier.
+ const vapidKeyBytes = Uint8Array.fromBase64(vapidKeyBase64, {
+ alphabet: 'base64url',
+ });
+ // In practice, `vapidKeyBytes.buffer` is going to be the same bytes as this slice, but in
+ // principle it could be a subset of the underlying buffer, and there's very little downside to
+ // being meticulous here.
+ this.vapidKey = vapidKeyBytes.buffer.slice(
+ vapidKeyBytes.byteOffset,
+ vapidKeyBytes.byteOffset + vapidKeyBytes.byteLength,
+ );
+
+ // Note that `resubscribe()` is async; this will start the [re]subscription process but will
+ // return to the caller before it completes. I'm not willing to make the entire event handling
+ // chain all the way back up to the EventSource asynchronous, since EventSource isn't designed
+ // to support that (we could make it work), and we can't wait for async functions in a non-async
+ // context, so this is the best we can do.
+ this.resubscribe();
+ }
+}
+
+function equalArrayBuffers(aBuffer, bBuffer) {
+ // You might be thinking, surely there's a way to compare two array buffers for equality. I
+ // certainly expected that there would be. [Nope].
+ //
+ // [Nope]: https://github.com/wbinnssmith/arraybuffer-equal
+ //
+ // The algorithm here is simple enough not to need an external dependency. However, this
+ // comparison is not designed to deal with detached ArrayBuffer instances.
+ const aBytes = new Uint8Array(aBuffer);
+ const bBytes = new Uint8Array(bBuffer);
+
+ return aBytes.length === bBytes.length && aBytes.every((value, index) => value === bBytes[index]);
+}
diff --git a/ui/lib/state/remote/state.svelte.js b/ui/lib/state/remote/state.svelte.js
index 8845e02..3d65e4a 100644
--- a/ui/lib/state/remote/state.svelte.js
+++ b/ui/lib/state/remote/state.svelte.js
@@ -7,7 +7,6 @@ export class State {
users = $state(new Users());
conversations = $state(new Conversations());
messages = $state(new Messages());
- vapid_key = $state(null);
static boot({ currentUser, heartbeat, resumePoint, events }) {
const state = new State({
@@ -37,8 +36,6 @@ export class State {
return this.onUserEvent(event);
case 'message':
return this.onMessageEvent(event);
- case 'vapid':
- return this.onVapidEvent(event);
}
}
@@ -91,16 +88,4 @@ export class State {
const { id } = event;
this.messages.remove(id);
}
-
- onVapidEvent(event) {
- switch (event.event) {
- case 'changed':
- return this.onVapidChanged(event);
- }
- }
-
- onVapidChanged(event) {
- let { key } = event;
- this.vapid_key = key;
- }
}
diff --git a/ui/routes/(app)/me/+page.svelte b/ui/routes/(app)/me/+page.svelte
index 0c960c8..ddb1245 100644
--- a/ui/routes/(app)/me/+page.svelte
+++ b/ui/routes/(app)/me/+page.svelte
@@ -2,10 +2,16 @@
import LogOut from '$lib/components/LogOut.svelte';
import Invites from '$lib/components/Invites.svelte';
import ChangePassword from '$lib/components/ChangePassword.svelte';
+ import PushSubscription from '$lib/components/PushSubscription.svelte';
import { goto } from '$app/navigation';
import * as api from '$lib/apiServer.js';
+ const { data } = $props();
+ const { session } = data;
+ const subscription = $derived(session.push.subscription);
+ const vapid = $derived(session.push.vapidKey);
+
let invites = $state([]);
async function logOut() {
@@ -25,10 +31,16 @@
invites.push(response.data);
}
}
+
+ async function subscribe() {
+ await session.push.subscribe();
+ }
</script>
<ChangePassword {changePassword} />
<hr />
+<PushSubscription {subscription} {vapid} {subscribe} />
+<hr />
<Invites {invites} {createInvite} />
<hr />
<LogOut {logOut} />