Sfoglia il codice sorgente

Merge branch 'goal/web-push' of clanker/clanker-saddle into web-stack

Billy Barrow 10 ore fa
parent
commit
22502bfeaf

+ 3 - 0
.gitignore

@@ -8,3 +8,6 @@ compile_commands.json
 # Python bytecode
 test/__pycache__/
 __pycache__/
+
+# E2E scratch workdirs (WEBSTACK_WORK=<repo>/build/ws)
+build/

+ 25 - 13
README.md

@@ -109,7 +109,8 @@ python3 ../saddle/test/mock_llm.py --port 9999
 Browse to `http://localhost:8080`, log in with the seeded admin account,
 register the saddle on `/saddles` with host `localhost:7100`, and create
 your first project and task. `spry dev` wraps the first shell (rebuild +
-restart on save); `web-config.json` (from `spry keys`) keeps sessions
+restart on save); `web-config.json` (from `spry keys` — the statum
+session pairs and the Web Push VAPID keys) keeps sessions
 alive across restarts and is never packaged or baked into images.
 
 The database is `./clanker-saddle.sqlite`, or `SPRY_DB_PATH` when set.
@@ -362,12 +363,24 @@ resume path.
 ## Browser notifications (Web Push)
 
 The app speaks the real Web Push protocol (VAPID + RFC 8291 payload
-encryption, no third-party service). The VAPID keypair is generated at
-startup on first run and persisted in **`web-config.json`** under the
-`vapid` section (`public_key` / `private_key`, base64url P-256) — that
-file is the single home for the keys; delete the section to rotate
-(regenerate = restart). `SADDLE_VAPID_SUBJECT` overrides the JWT's
-`mailto:` contact (default `mailto:clanker-saddle@localhost`).
+encryption, no third-party service) on top of `Spry.Push`: Spry owns
+the subscription store (`spry_push_subscriptions`), the crypto and the
+delivery POST; the app owns the payloads, the audiences and the bell.
+Keys are operator-supplied, never generated at boot: **`spry keys`**
+writes the statum session keys AND the `spry` section's
+`vapid_private_key` / `vapid_public_key` (base64url P-256) into
+`web-config.json` — that file is the single home for the keys; delete
+the pair (or the whole section) and re-run `spry keys` to rotate (a
+regenerated pair invalidates existing push subscriptions; the tool
+warns loudly). Set `vapid_subject` in the same section to a real
+`mailto:` (or `https:`) contact — push services use it to reach you
+when something is wrong; the placeholder `mailto:admin@example.org`
+is only a stand-in and is warned about in the logs. An app booted
+without keys does not fail: sends are SKIPPED with a prominent
+warning telling the operator to run `spry keys`, the bell hides its
+enable button (no key to subscribe with), and subscriptions still
+work — only delivery is postponed. Keys written after boot are picked
+up on the next send without a restart.
 
 To enable notifications in a browser: open the dashboard and press
 **Enable notifications** — the page requests permission, registers the
@@ -386,12 +399,11 @@ opts you into that task's first start, failure and completion; task
 questions, RFCs and review requests notify every subscribed user, and
 goal blocks plus goal success/failure ALWAYS notify every subscribed
 user. Clicking a notification opens the task's or goal's page.
-Delivery is best-effort and truthful: failed sends retry on the
-dispatcher tick (up to five attempts — after which the drop is logged
-loudly, never silently claimed as delivered), endpoints answering
-410/404 are pruned automatically, and other 4xx refusals (e.g. a VAPID
-rejection) keep the subscription row and retry, since the endpoint
-may recover.
+Delivery is best-effort and truthful: each send retries inline
+(two attempts, 250 ms apart — a failure after that is logged loudly,
+never silently claimed as delivered), endpoints answering 404/410
+(gone) or 401/403 (refused) are pruned automatically, and other
+failures keep the subscription row — the endpoint may recover.
 
 ## Deleting a project
 

+ 63 - 4
docs/spry-conversion.md

@@ -1,9 +1,10 @@
-# Spry conversion conventions (goal 41, binding for T3–T10)
+# Spry conversion conventions
 
 ClankerSaddle runs on the Spry CSS framework that ships inside
-libspry. This document is the binding contract for every page task in
-the UI/UX goal: what Spry provides, what `app.css` owns, how classes
-map from the old stylesheet, and the rules for new markup.
+libspry. This document is the binding contract for the UI/UX page
+tasks: what Spry provides, what `app.css` owns, how classes map from
+the old stylesheet, the rules for new markup — and the app-side
+contracts of the Spry services the app adopts (Web Push).
 
 ## How the framework is loaded
 
@@ -386,3 +387,61 @@ The audit is wired into the suite as the `t12mobile` bundle: the
 server-side invariants run always; the browser audit runs when
 chromium is present (CI without chromium skips it gracefully — pass
 `T12MOBILE_BROWSER=1` to require it).
+
+## Web Push (the app side of `Spry.Push`)
+
+Web Push plumbing is Spry's, delivered by `SpryModule`
+(`application.add_module<SpryModule>()` in `main.vala` registers it):
+
+- **`Spry.Push.PushService`** — the subscription store over the
+  `spry_push_subscriptions` table (Spry's own migration creates it):
+  `subscribe` / `unsubscribe` / `subscriptions_for` / `forget`.
+- **`Spry.Push.PushSender`** — the singleton delivery engine:
+  Locksmith RFC 8291 payload encryption + RFC 8292 VAPID signing and
+  the libsoup POST, with inline bounded retry (two attempts, 250 ms
+  apart). Endpoints answering 404/410 (gone) or 401/403 (refused) are
+  swept from the store; other failures keep the row. Without keys
+  every send answers `SKIPPED` — loudly warned, never an error.
+
+Keys are operator-supplied (`spry keys` writes the statum pairs AND
+the `spry` section's `vapid_private_key` / `vapid_public_key`; see the
+README's operations section). The app never generates, stores or
+rotates key material.
+
+What OUR side owns (`src/Services/PushService.vala` — the thin app
+layer; it never touches a key or a socket):
+
+- **Payload builders** — `block_notification`,
+  `lifecycle_notification`, `goal_block_notification`,
+  `goal_terminal_notification`, `test_notification`: pure functions
+  producing the `{title, body, url}` wire payload.
+- **Audiences** — global (every push-subscribed user: block events,
+  goal blocks, goal terminals) vs opted-in (users holding a
+  `task_notify_prefs` row: a task's first start, failure, completion).
+  Dispatch is fire-and-forget at event time through a fresh container
+  scope — no queue, no dispatcher tick.
+- **Notify prefs** — the `task_notify_prefs` CRUD (pure app domain).
+- **Fingerprints** — `endpoint_fingerprint` (first 16 hex of SHA-256)
+  powers the per-browser bell without ever exposing an endpoint URL.
+- **`send_test`** — the bell's end-to-end probe, mapping Spry's
+  `DeliveryOutcome[]` to the toast's delivered/failed/pruned counts.
+
+The subscribe/unsubscribe actions (`src/actions/PushActions.vala`)
+keep our wording and the navigate-to-`/` refresh but call Spry's store
+underneath; they subclass nothing.
+
+The service worker `GET /sw.js` (`src/Api/PushApi.vala`) is
+deliberately **app-owned**: Spry's model leaves `sw.js` to the
+application because the click-through routing and the payload shape
+are the app's. It parses our `{title, body, url}` payload and focuses
+or opens the task/goal page on click.
+
+The bell UX (`initPushBell` in `src/pages/app.js`, rendered by the
+`.push-bell-card` on the dashboard rail and `/profile`) is OUR UI
+contract. Spry ships a client script (`spry-push.js` at
+`/_statum/resource/`) — it is deliberately NOT used: the bell's
+per-browser fingerprint matching, the enable/turn-off/test trio and
+the unkeyed state (no VAPID key in the page state => Enable hidden,
+helper text points the operator at `spry keys`) predate and differ
+from it. Do not "simplify" the bell onto `spry-push.js` without
+redesigning that contract.

+ 2 - 11
meson.build

@@ -26,10 +26,6 @@ spry_dep = dependency('spry-0.2')
 # pkg-config libdir as the INSTALL rpath — inside a usm container the
 # same expression resolves the (harmless, existing) system libdir.
 devstack_libdir = spry_dep.get_variable(pkgconfig: 'libdir', default_value: '')
-# Web Push crypto primitives (P-256 ECDH/ECDSA + AES-128-GCM for VAPID
-# and RFC 8291 payload encryption — plan 11 task E). Found via the C
-# compiler (no Vala vapi exists for libcrypto; the shim is C-only).
-libcrypto_dep = meson.get_compiler('c').find_library('crypto')
 
 # Statum build tools — statum-mkpstm compiles pages, statum-mkres embeds
 # resources. Resolved from PATH at setup time.
@@ -228,6 +224,7 @@ app_sources = files(
     'src/Data/Migrations/M0009_TurnCapPark.vala',
     'src/Data/Migrations/M0010_ModelConfig.vala',
     'src/Data/Migrations/M0011_UserPreferences.vala',
+    'src/Data/Migrations/M0012_ProviderPushSubscriptions.vala',
     'src/Services/ProjectService.vala',
     'src/Services/TaskService.vala',
     'src/Services/GoalService.vala',
@@ -294,10 +291,6 @@ app_sources = files(
     # spry:sources-end
 )
 
-# Web Push crypto shim (plan 11 task E): C over OpenSSL's libcrypto —
-# P-256 keygen/ECDH/ECDSA + AES-128-GCM behind web-push-crypto.vapi.
-push_crypto = files('src/Push/web-push-crypto.c')
-
 app_generated = [
     # spry:generated-begin
     home_page,
@@ -323,8 +316,7 @@ app_generated = [
 ]
 
 clanker_saddle_exe = executable('clanker-saddle',
-    app_sources + app_generated + [app_css, app_js, task_log_js, favicon_png, emblem_png, push_crypto],
-    include_directories: include_directories('src/Push'),
+    app_sources + app_generated + [app_css, app_js, task_log_js, favicon_png, emblem_png],
     vala_args: ['--vapidir=' + join_paths(meson.current_source_dir(), 'vapi')],
     dependencies: [
         spry_dep,
@@ -342,7 +334,6 @@ clanker_saddle_exe = executable('clanker-saddle',
         gio_dep,
         gobject_dep,
         glib_dep,
-        libcrypto_dep,
     ],
     install: true,
     install_rpath: devstack_libdir

+ 2 - 2
src/Api/PushApi.vala

@@ -6,8 +6,8 @@ using ClankerSaddle.Services;
 namespace ClankerSaddle.Api {
 
     /**
-     * `GET /sw.js` (plan 11 task E) — the Web Push service worker, a
-     * static asset served at the origin root so its scope covers every
+     * `GET /sw.js` — the Web Push service worker, a static asset
+     * served at the origin root so its scope covers every
      * page. The push handler shows the notification the app built
      * ({title, body, url}); `notificationclick` focuses an open window
      * on the task page or opens one. Deliberately unauthenticated: the

+ 4 - 31
src/Data/Entities.vala

@@ -594,35 +594,8 @@ namespace ClankerSaddle.Data {
     }
 
     /**
-     * A user's Web Push subscription endpoint (plan 11 task E shape):
-     * `p256dh`/`auth` are the browser's application server keys; the
-     * endpoint URL is unique per browser. `user_id` references the
-     * Spry users table by value (no FK — the auth schema owns that
-     * table).
-     */
-    public class PushSubscription : Object {
-
-        public int64 id { get; set; }
-        public int64 user_id { get; set; }
-        public string endpoint { get; set; default = ""; }
-        public string p256dh { get; set; default = ""; }
-        public string auth { get; set; default = ""; }
-        public DateTime? created_at { get; set; default = null; }
-
-        public static void entity_mapping(EntityMapperBuilder<PushSubscription> cfg) {
-            cfg.table("push_subscriptions")
-                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
-                .column<int64?>("user_id", o => o.user_id, (o, v) => o.user_id = v)
-                .column<string>("endpoint", o => o.endpoint, (o, v) => o.endpoint = v)
-                .column<string>("p256dh", o => o.p256dh, (o, v) => o.p256dh = v)
-                .column<string>("auth", o => o.auth, (o, v) => o.auth = v)
-                .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v);
-        }
-    }
-
-    /**
-     * A user's per-task "Notify me" opt-in (plan 11 task E shape): the
-     * task's first start, failure and completion notify the opted-in
+     * A user's per-task "Notify me" opt-in: the task's first start,
+     * failure and completion notify the opted-in
      * user; block events notify all push-subscribed users globally.
      */
     public class TaskNotifyPref : Object {
@@ -840,8 +813,8 @@ namespace ClankerSaddle.Data {
      * column and must not touch this table.
      *
      * `user_id` references the Spry users table by value (no FK — the
-     * auth schema owns that table, the same posture as {@link
-     * PushSubscription}).
+     * auth schema owns that table, the same posture as the Spry-owned
+     * push subscription store).
      */
     public class UserPreference : Object {
 

+ 1 - 2
src/Data/Migrations/M0011_UserPreferences.vala

@@ -12,8 +12,7 @@
          * need no further migrations.
          *
          * `user_id` references the Spry users table BY VALUE (no FK —
-         * the auth schema owns that table, the same posture as
-         * `push_subscriptions`).
+         * the auth schema owns that table).
          *
          * IDEMPOTENCE: same contract as M0002–M0010 — up() runs before
          * the apply transaction opens (the builder only queues), so it

+ 111 - 0
src/Data/Migrations/M0012_ProviderPushSubscriptions.vala

@@ -0,0 +1,111 @@
+    using InvercargillSql.Migrations;
+    using Sqlite;
+
+    namespace ClankerSaddle.Data.Migrations {
+
+        /**
+         * Moves Web Push subscriptions from our former hand-rolled
+         * `push_subscriptions` table into Spry's
+         * `spry_push_subscriptions` store (goal 47: the app adopted
+         * `Spry.Push` for storage, crypto and delivery — our own RFC
+         * 8291/8292 implementation never worked and is gone).
+         *
+         * Column mapping is one-to-one (`created_at` → `created`); the
+         * copy is INSERT OR IGNORE because the Spry table enforces
+         * UNIQUE(endpoint) — a database that already carried both
+         * shapes keeps its newer rows. The copy is ONE-WAY: `down()`
+         * recreates the old table per M0001's shape, empty, for
+         * operators who must roll back (the subscriptions themselves
+         * are re-created by browsers on their next visit — pushing to
+         * stale endpoints never worked anyway).
+         *
+         * ORDERING: the copy may only run after Spry's own
+         * M0003_PushSubscriptions ("spry-authentication" namespace)
+         * has created `spry_push_subscriptions`. The runner's Kahn
+         * queue is namespace-first, so a bare clanker-saddle migration
+         * lands BEFORE the spry namespace on a fresh database
+         * (empirically: clanker-saddle:1, spry:1, clanker-saddle:12,
+         * spry:2..5). The `dependencies` declaration below pins this
+         * migration after the WHOLE spry-authentication namespace —
+         * verified empirically on a scratch database: with it, the
+         * order becomes clanker-saddle:1, spry:1..5,
+         * clanker-saddle:12. On an already-migrated database both
+         * namespaces are applied, the copy finds both tables and the
+         * dependency costs nothing.
+         *
+         * IDEMPOTENCE: same contract as M0002–M0011 — up() runs before
+         * the apply transaction opens (the builder only queues), so it
+         * introspects the schema through a separate read-only handle
+         * and copies only when the old table still exists. The DROP is
+         * likewise conditional (guarded in the SQL itself, since the
+         * builder has no drop-if-exists).
+         */
+        public class M0012_ProviderPushSubscriptions : Migration {
+
+            public override string name { get { return "Move push subscriptions to Spry"; } }
+            public override uint64 serial { get { return 12; } }
+            public override string migration_namespace { get { return "clanker-saddle"; } }
+            public override owned string[] dependencies {
+                owned get { return { "spry-authentication" }; }
+            }
+
+            // The runner never hands up() a connection, so the schema is
+            // read through a read-only handle on the SPRY_DB_PATH main.vala
+            // connects to.
+            private string db_path() {
+                var path = Environment.get_variable("SPRY_DB_PATH") ?? "";
+                if (path.strip().length == 0) {
+                    return "./clanker-saddle.sqlite";
+                }
+                return path;
+            }
+
+            private bool table_exists(string table) {
+                Database db;
+                int rc = Database.open_v2(db_path(), out db, Sqlite.OPEN_READONLY);
+                if (rc != Sqlite.OK) {
+                    return false;
+                }
+                Statement statement;
+                rc = db.prepare_v2(
+                    @"select 1 from sqlite_master where type = 'table' and name = '$table'",
+                    -1, out statement);
+                if (rc != Sqlite.OK) {
+                    return false;
+                }
+                return statement.step() == Sqlite.ROW;
+            }
+
+            public override void up(MigrationBuilder b) {
+                if (!table_exists("push_subscriptions")) {
+                    // Fresh database (or already moved): nothing to copy.
+                    return;
+                }
+                b.execute_sql(@"
+                    insert or ignore into spry_push_subscriptions
+                        (user_id, endpoint, p256dh, auth, created)
+                    select user_id, endpoint, p256dh, auth, created_at
+                        from push_subscriptions");
+                b.execute_sql("drop table if exists push_subscriptions");
+            }
+
+            public override void down(MigrationBuilder b) {
+                b.create_table("push_subscriptions", (t) => {
+                    t.column<int64?>("id")
+                        .auto_increment()
+                        .primary_key();
+                    t.column<int64?>("user_id")
+                        .not_null()
+                        .indexed();
+                    t.column<string>("endpoint")
+                        .not_null()
+                        .unique();
+                    t.column<string>("p256dh")
+                        .not_null();
+                    t.column<string>("auth")
+                        .not_null();
+                    t.column<DateTime?>("created_at");
+                });
+            }
+        }
+    }

+ 0 - 192
src/Push/web-push-crypto.c

@@ -1,192 +0,0 @@
-/* Web Push crypto primitives (plan 11 task E): the P-256 and AES-GCM
-   operations VAPID + RFC 8291 payload encryption need, as plain C over
-   OpenSSL's libcrypto. Raw in, raw out — the Vala side owns HKDF,
-   base64url and the envelope/JWT shapes.
-
-   Every function returns 1 on success, 0 on failure (bad input lengths
-   or an OpenSSL error); callers fail the send, never the request. */
-#define OPENSSL_SUPPRESS_DEPRECATED
-#include <openssl/bn.h>
-#include <openssl/ec.h>
-#include <openssl/ecdh.h>
-#include <openssl/ecdsa.h>
-#include <openssl/evp.h>
-#include <openssl/rand.h>
-#include <string.h>
-#include "web-push-crypto.h"
-
-static EC_GROUP *p256_group(void)
-{
-	static EC_GROUP *group = NULL;
-	if (group == NULL)
-		group = EC_GROUP_new_by_curve_name(NID_X9_62_prime256v1);
-	return group;
-}
-
-static EC_KEY *key_from_private(const unsigned char priv[32])
-{
-	BIGNUM *scalar = BN_bin2bn(priv, 32, NULL);
-	EC_KEY *key = EC_KEY_new();
-	EC_POINT *public_point = NULL;
-	if (key == NULL || scalar == NULL)
-		goto fail;
-	if (EC_KEY_set_group(key, p256_group()) != 1)
-		goto fail;
-	if (EC_KEY_set_private_key(key, scalar) != 1)
-		goto fail;
-	public_point = EC_POINT_new(p256_group());
-	if (public_point == NULL)
-		goto fail;
-	if (EC_POINT_mul(p256_group(), public_point, scalar, NULL, NULL, NULL) != 1)
-		goto fail;
-	if (EC_KEY_set_public_key(key, public_point) != 1)
-		goto fail;
-	EC_POINT_free(public_point);
-	BN_free(scalar);
-	return key;
-fail:
-	EC_POINT_free(public_point);
-	BN_free(scalar);
-	EC_KEY_free(key);
-	return NULL;
-}
-
-int ws_p256_generate(unsigned char priv[32], unsigned char pub[65])
-{
-	EC_KEY *key = EC_KEY_new_by_curve_name(NID_X9_62_prime256v1);
-	const BIGNUM *scalar = NULL;
-	const EC_POINT *point = NULL;
-	size_t written = 0;
-	int ok = 0;
-
-	if (key == NULL)
-		return 0;
-	if (EC_KEY_generate_key(key) != 1)
-		goto out;
-	scalar = EC_KEY_get0_private_key(key);
-	point = EC_KEY_get0_public_key(key);
-	if (scalar == NULL || point == NULL)
-		goto out;
-	if (BN_num_bytes(scalar) > 32)
-		goto out;
-	memset(priv, 0, 32);
-	BN_bn2bin(scalar, priv + (32 - BN_num_bytes(scalar)));
-	written = EC_POINT_point2oct(p256_group(), point,
-		POINT_CONVERSION_UNCOMPRESSED, pub, 65, NULL);
-	ok = written == 65;
-out:
-	EC_KEY_free(key);
-	return ok;
-}
-
-int ws_p256_public_from_private(const unsigned char priv[32],
-                                unsigned char pub[65])
-{
-	EC_KEY *key = key_from_private(priv);
-	const EC_POINT *point = NULL;
-	int ok = 0;
-
-	if (key == NULL)
-		return 0;
-	point = EC_KEY_get0_public_key(key);
-	if (point != NULL)
-		ok = EC_POINT_point2oct(p256_group(), point,
-			POINT_CONVERSION_UNCOMPRESSED, pub, 65, NULL) == 65;
-	EC_KEY_free(key);
-	return ok;
-}
-
-int ws_p256_ecdh(const unsigned char priv[32], const unsigned char peer[65],
-                 unsigned char secret[32])
-{
-	EC_KEY *key = key_from_private(priv);
-	EC_POINT *peer_point = NULL;
-	int ok = 0;
-
-	if (key == NULL)
-		return 0;
-	peer_point = EC_POINT_new(p256_group());
-	if (peer_point == NULL)
-		goto out;
-	if (EC_POINT_oct2point(p256_group(), peer_point, peer, 65, NULL) != 1)
-		goto out;
-	if (EC_POINT_is_on_curve(p256_group(), peer_point, NULL) != 1)
-		goto out;
-	if (ECDH_compute_key(secret, 32, peer_point, key, NULL) != 32)
-		goto out;
-	ok = 1;
-out:
-	EC_POINT_free(peer_point);
-	EC_KEY_free(key);
-	return ok;
-}
-
-/* ES256's raw r||s (64 bytes) over a 32-byte SHA-256 digest. */
-int ws_p256_sign(const unsigned char priv[32], const unsigned char digest[32],
-                 unsigned char sig[64])
-{
-	EC_KEY *key = key_from_private(priv);
-	ECDSA_SIG *signature = NULL;
-	const BIGNUM *r = NULL;
-	const BIGNUM *s = NULL;
-	int ok = 0;
-
-	if (key == NULL)
-		return 0;
-	signature = ECDSA_do_sign(digest, 32, key);
-	if (signature == NULL)
-		goto out;
-	ECDSA_SIG_get0(signature, &r, &s);
-	if (BN_num_bytes(r) > 32 || BN_num_bytes(s) > 32)
-		goto out;
-	memset(sig, 0, 64);
-	BN_bn2bin(r, sig + (32 - BN_num_bytes(r)));
-	BN_bn2bin(s, sig + 32 + (32 - BN_num_bytes(s)));
-	ok = 1;
-out:
-	ECDSA_SIG_free(signature);
-	EC_KEY_free(key);
-	return ok;
-}
-
-/* AES-128-GCM (16-byte key, 12-byte IV): out receives ciphertext ||
-   16-byte tag; *out_len is the total. out must hold pt_len + 16. */
-int ws_aes128gcm_encrypt(const unsigned char *key, const unsigned char *iv,
-                         const unsigned char *plaintext, size_t pt_len,
-                         unsigned char *out, size_t *out_len)
-{
-	EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();
-	size_t total = 0;
-	int written = 0;
-	int ok = 0;
-
-	if (ctx == NULL)
-		return 0;
-	if (EVP_EncryptInit_ex(ctx, EVP_aes_128_gcm(), NULL, NULL, NULL) != 1)
-		goto out;
-	if (EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, 12, NULL) != 1)
-		goto out;
-	if (EVP_EncryptInit_ex(ctx, NULL, NULL, key, iv) != 1)
-		goto out;
-	if (pt_len > 0
-	    && EVP_EncryptUpdate(ctx, out, &written, plaintext, (int) pt_len) != 1)
-		goto out;
-	total = (size_t) written;
-	if (EVP_EncryptFinal_ex(ctx, out + total, &written) != 1)
-		goto out;
-	total += (size_t) written;
-	if (EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_GET_TAG, 16, out + total) != 1)
-		goto out;
-	total += 16;
-	*out_len = total;
-	ok = 1;
-out:
-	EVP_CIPHER_CTX_free(ctx);
-	return ok;
-}
-
-/* CSPRNG bytes (OpenSSL's RAND): 1 on success. */
-int ws_random(unsigned char *buf, size_t len)
-{
-	return RAND_bytes(buf, (int) len) == 1;
-}

+ 0 - 42
src/Push/web-push-crypto.h

@@ -1,42 +0,0 @@
-/* See web-push-crypto.c — raw P-256 / AES-GCM primitives for Web Push. */
-#ifndef CLANKER_SADDLE_WEB_PUSH_CRYPTO_H
-#define CLANKER_SADDLE_WEB_PUSH_CRYPTO_H
-
-#include <stddef.h>
-
-#ifdef __cplusplus
-extern "C" {
-#endif
-
-/* Generates a P-256 keypair: priv = 32-byte scalar, pub = 65-byte
-   uncompressed point (0x04 || X || Y). */
-int ws_p256_generate(unsigned char priv[32], unsigned char pub[65]);
-
-/* Derives the 65-byte public point of a 32-byte private scalar. */
-int ws_p256_public_from_private(const unsigned char priv[32],
-                                unsigned char pub[65]);
-
-/* ECDH: the 32-byte shared x coordinate of priv and the 65-byte peer
-   point. */
-int ws_p256_ecdh(const unsigned char priv[32], const unsigned char peer[65],
-                 unsigned char secret[32]);
-
-/* ECDSA over a 32-byte digest; sig = raw 64-byte r||s (the ES256/JWS
-   signature form). */
-int ws_p256_sign(const unsigned char priv[32], const unsigned char digest[32],
-                 unsigned char sig[64]);
-
-/* AES-128-GCM (16-byte key, 12-byte IV); out holds ciphertext || 16-byte
-   tag, so must have pt_len + 16 bytes of capacity. */
-int ws_aes128gcm_encrypt(const unsigned char *key, const unsigned char *iv,
-                         const unsigned char *plaintext, size_t pt_len,
-                         unsigned char *out, size_t *out_len);
-
-/* CSPRNG bytes (OpenSSL RAND). */
-int ws_random(unsigned char *buf, size_t len);
-
-#ifdef __cplusplus
-}
-#endif
-
-#endif

+ 1 - 1
src/Services/AllocationService.vala

@@ -588,7 +588,7 @@ namespace ClankerSaddle.Services {
 
             yield saddles.mark_task_comments_delivered(task.id);
             yield pulse.bump(task.id, "task dispatched");
-            // Web Push (plan 11 task E): the FIRST dispatch of a task
+            // Web Push: the FIRST dispatch of a task
             // notifies its opted-in users — fired from the same place
             // the dispatch pulse bumps (single funnel).
             if (claim.first_start) {

+ 0 - 5
src/Services/Dispatcher.vala

@@ -29,7 +29,6 @@ namespace ClankerSaddle.Services {
 
         private Container container = inject<Container>();
         private SaddlePollState poll_state = inject<SaddlePollState>();
-        private PushService push = inject<PushService>();
         private AnswerDeliveryHub answer_hub = inject<AnswerDeliveryHub>();
         private bool ticking = false;
         private int64 tick_started_ms = 0;
@@ -140,10 +139,6 @@ namespace ClankerSaddle.Services {
                     warning("[ClankerSaddle] Answer delivery pass failed: %s", e.message);
                 }
 
-                // Web Push retry tick (plan 11 task E): anything a failed
-                // send left queued retries here (best-effort delivery).
-                yield push.flush();
-
                 foreach (var saddle in yield saddles.list_busy_saddles()) {
                     // Goal 28 (the ghost-working sweep): a busy record
                     // whose session has been push-silent past

+ 219 - 654
src/Services/PushService.vala

@@ -4,41 +4,18 @@ using Invercargill.Expressions;
 using InvercargillSql;
 using InvercargillSql.Orm;
 using Inversion;
-using Soup;
-using Astralis;
+using Spry.Push;
 using ClankerSaddle.Data;
 
 namespace ClankerSaddle.Services {
 
-    // ---- OpenSSL shim (src/Push/web-push-crypto.c): raw P-256 and
-    // AES-GCM primitives. Pointer parameters deliberately — no array
-    // lengths cross the boundary. ----
-    [CCode (cname = "ws_p256_generate")]
-    internal static extern int crypto_p256_generate(uint8 *priv, uint8 *pub);
-
-    [CCode (cname = "ws_p256_public_from_private")]
-    internal static extern int crypto_p256_public_from_private(uint8 *priv, uint8 *pub);
-
-    [CCode (cname = "ws_p256_ecdh")]
-    internal static extern int crypto_p256_ecdh(uint8 *priv, uint8 *peer, uint8 *secret);
-
-    [CCode (cname = "ws_p256_sign")]
-    internal static extern int crypto_p256_sign(uint8 *priv, uint8 *digest, uint8 *sig);
-
-    [CCode (cname = "ws_aes128gcm_encrypt")]
-    internal static extern int crypto_aes128gcm_encrypt(uint8 *key, uint8 *iv,
-        uint8 *plaintext, size_t pt_len, uint8 *out, out size_t out_len);
-
-    [CCode (cname = "ws_random")]
-    internal static extern int crypto_random(uint8 *buf, size_t len);
-
     /**
-     * One outgoing notification (plan 11 task E): `title` carries the
-     * task title plus the event kind, `body` the question or summary,
-     * `url` the task page the service worker routes a click to. Pure
-     * data — the payload builder below is deterministic so tests can
-     * assert on it end-to-end (the test harness owns the client keys,
-     * so it can decrypt what was sent).
+     * One outgoing notification: `title` carries the task title plus
+     * the event kind, `body` the question or summary, `url` the task
+     * page the service worker routes a click to. Pure data — the
+     * payload builder below is deterministic so tests can assert on
+     * it end-to-end (the test harness owns the client keys, so it can
+     * decrypt what was sent).
      */
     public class PushNotification : Object {
         public string title { get; set; default = ""; }
@@ -49,198 +26,73 @@ namespace ClankerSaddle.Services {
     /**
      * The roll-up of one {@link PushService.send_test} probe: counts
      * per outcome plus the first failure's detail for the toast.
-     * `first_failure` names the HTTP status ("HTTP 401") or the
-     * transport error text — the dashboard's test button toasts the
-     * REAL end-to-end result, never a blind success.
+     * `first_failure` names why a browser was not reached — the
+     * dashboard's test button toasts the REAL end-to-end result,
+     * never a blind success.
      */
     public class PushTestReport : Object {
         public int delivered { get; set; }
         public int failed { get; set; }
-        /** Endpoints that answered 404/410 — pruned during the probe. */
+        /** Endpoints the sender swept — 404/410 gone, 401/403 refused. */
         public int pruned { get; set; }
         public string first_failure { get; set; default = ""; }
     }
 
     /**
-     * Web Push delivery (plan 11 task E): real Web Push protocol over
-     * VAPID. The keypair is generated once and persisted in the config
-     * dir (a `vapid` section inside web-config.json); payloads are
-     * encrypted per RFC 8291 (ECDH P-256 + HKDF + AES-128-GCM,
-     * aes128gcm content coding) and authorised by an ES256 VAPID JWT.
+     * The app's Web Push layer over `Spry.Push`: payloads,
+     * audiences and notify prefs are OUR domain — Spry owns the store
+     * (`spry_push_subscriptions`), the RFC 8291/8292 crypto and the
+     * delivery POST. This class never touches a key or a socket.
+     *
+     * Targeting: BLOCK events and goal blocks/terminals go to every
+     * push-subscribed user globally; a task's first start, failure and
+     * completion go only to users holding a `task_notify_prefs` row
+     * for it ("Notify me").
      *
-     * Targeting: BLOCK events
-     * condition go to every push-subscribed user globally; a task's
-     * first start, failure and completion go only to users holding a
-     * `task_notify_prefs` row for it ("Notify me").
+     * Delivery follows Spry's semantics: at event time the audience is
+     * resolved and each user's payload handed to {@link
+     * Spry.Push.PushSender.send_to_user}, which encrypts, POSTs and
+     * sweeps dead rows inline (bounded retry inside the sender). No
+     * queue, no dispatcher tick — the dispatch is fire-and-forget so
+     * events never block on a push service.
      *
-     * Delivery is best-effort from a singleton queue: enqueues are
-     * in-memory (they never block the request), a flush is kicked
-     * immediately and re-run by the dispatcher's tick while anything
-     * remains (a failed send retries next tick, up to
-     * {@link MAX_ATTEMPTS}); endpoints answering 410 Gone (or 404) are
-     * pruned — the browser already destroyed the subscription.
+     * Injection: the container is captured in a field initialiser and
+     * every store/sender resolution runs through a FRESH scope created
+     * inside the call (never a bare `inject<>` outside a
+     * materialisation window).
      */
     public class PushService : Object {
 
-        private const int MAX_ATTEMPTS = 5;
-
-        /** The persisted keypair, base64url (empty before {@link ensure_vapid_keys}). */
-        public static string vapid_public_key { get; private set; default = ""; }
-        private static string vapid_private_key = "";
+        /** How long the push service should keep a message (seconds). */
+        private const int TTL_SECONDS = 60;
 
         private Container container = inject<Container>();
-        private WebConfig web_config = inject<WebConfig>();
-        private Soup.Session http = new Soup.Session() {
-            timeout = 10,
-            idle_timeout = 10
-        };
-
-        /** {@link ensure_vapid_keys} with the injected config (main's startup call). */
-        public void ensure_keys_from_config() {
-            ensure_vapid_keys(web_config);
-        }
-
-        /** A resolved send: one endpoint with its keys and plaintext payload. */
-        private class SendJob : Object {
-            public string endpoint = "";
-            public string p256dh = "";
-            public string auth = "";
-            public string payload = "";
-            public int attempts = 0;
-            /** The last HTTP status the endpoint answered (0 = transport error). */
-            public int last_status = 0;
-        }
-
-        /** A queued notification before target resolution. */
-        private class PendingTarget : Object {
-            public bool global = false;
-            public int64 task_id = 0;
-            public PushNotification notification;
-        }
-
-        private GLib.Queue<PendingTarget> pending = new GLib.Queue<PendingTarget>();
-        private GLib.Queue<SendJob> queued = new GLib.Queue<SendJob>();
-        private Mutex queue_lock = Mutex();
-        private bool flushing = false;
-
-        // ------------------------------------------------------------------
-        // VAPID keys
-        // ------------------------------------------------------------------
-
-        /**
-         * Loads the VAPID keypair from the config's `vapid` section,
-         * generating and persisting a fresh one when missing (or when
-         * the stored pair does not match — the public point is derived
-         * from the private scalar and compared). Called once from
-         * `main` before the server accepts requests, so every
-         * subscriber always sees the same public key.
-         */
-        public static void ensure_vapid_keys(WebConfig config) {
-            string stored_public = "";
-            string stored_private = "";
-            var section = config != null ? config.get_section("vapid") : null;
-            if (section != null) {
-                stored_public = ((!)section).get_string("public_key", "");
-                stored_private = ((!)section).get_string("private_key", "");
-            }
-            if (keypair_valid(stored_public, stored_private)) {
-                vapid_public_key = stored_public;
-                vapid_private_key = stored_private;
-                return;
-            }
-
-            var priv = new uint8[32];
-            var pub = new uint8[65];
-            if (crypto_p256_generate((uint8*) priv, (uint8*) pub) != 1) {
-                warning("[ClankerSaddle] VAPID keypair generation failed;"
-                    + " web push disabled for this run");
-                return;
-            }
-            vapid_private_key = base64url_encode(priv);
-            vapid_public_key = base64url_encode(pub);
-            persist_vapid_keys(config);
-            print("[ClankerSaddle] Generated VAPID keypair (public key: %s)\n",
-                vapid_public_key);
-        }
-
-        /** True when the stored pair decodes and the public point matches. */
-        private static bool keypair_valid(string public_b64, string private_b64) {
-            uint8[] priv = base64url_decode(private_b64);
-            uint8[] pub = base64url_decode(public_b64);
-            if (priv.length != 32 || pub.length != 65 || pub[0] != 0x04) {
-                return false;
-            }
-            var derived = new uint8[65];
-            if (crypto_p256_public_from_private((uint8*) priv, (uint8*) derived) != 1) {
-                return false;
-            }
-            for (var index = 0; index < 65; index++) {
-                if (derived[index] != pub[index]) {
-                    return false;
-                }
-            }
-            return true;
-        }
 
         /**
-         * Writes the `vapid` section into the config file the app
-         * loaded (first loaded file; when nothing was loaded, the first
-         * discovered path — created). Existing keys and sections are
-         * preserved: the file is re-parsed and only `vapid` replaced.
+         * The VAPID public key browsers take as their
+         * `applicationServerKey`, as written by `spry keys` into the
+         * "spry" config section — "" when unkeyed (the bell then hides
+         * its enable button). The app no longer generates or persists
+         * key material anywhere.
          */
-        private static void persist_vapid_keys(WebConfig config) {
-            string path = "";
-            string[] loaded = config != null ? config.get_loaded_files() : new string[0];
-            if (loaded.length > 0) {
-                path = loaded[0];
-            } else {
-                try {
-                    string[] discovered = WebConfigLoader.discover_config_paths();
-                    if (discovered.length > 0) {
-                        path = discovered[0];
-                    }
-                } catch (Error e) {
-                    path = "";
-                }
-            }
-            if (path.length == 0) {
-                warning("[ClankerSaddle] No config file to persist the VAPID"
-                    + " keypair into; keys are in-memory for this run only");
-                return;
-            }
-
+        public string vapid_public_key() {
+            string key = "";
             try {
-                string text = "{}";
-                if (FileUtils.test(path, FileTest.EXISTS)) {
-                    string contents;
-                    FileUtils.get_contents(path, out contents);
-                    text = contents.strip().length > 0 ? contents : "{}";
+                var scope = container.create_scope();
+                try {
+                    key = scope.resolve<PushSender>().vapid_public_key();
+                } finally {
+                    scope.release();
                 }
-                var parser = new Json.Parser();
-                parser.load_from_data(text);
-                var root = parser.get_root();
-                var document = root != null && root.get_node_type() == Json.NodeType.OBJECT
-                    ? root.get_object() : new Json.Object();
-
-                var vapid = new Json.Object();
-                vapid.set_string_member("public_key", vapid_public_key);
-                vapid.set_string_member("private_key", vapid_private_key);
-                document.set_object_member("vapid", vapid);
-
-                var generator = new Json.Generator() {
-                    pretty = true,
-                    indent = 2
-                };
-                generator.set_root(new Json.Node(Json.NodeType.OBJECT).init_object(document));
-                FileUtils.set_contents(path, generator.to_data(null) + "\n");
             } catch (Error e) {
-                warning("[ClankerSaddle] Persisting the VAPID keypair into %s"
-                    + " failed: %s", path, e.message);
+                warning("[ClankerSaddle] could not resolve the VAPID key: %s",
+                    e.message);
             }
+            return key;
         }
 
         // ------------------------------------------------------------------
-        // Payload builder
+        // Payload builders (pure app domain)
         // ------------------------------------------------------------------
 
         /** The human label of a block kind in notification titles. */
@@ -333,424 +185,178 @@ namespace ClankerSaddle.Services {
             };
         }
 
+        /** The wire payload JSON (compact, exactly what the SW parses). */
+        private static string payload_json(PushNotification notification) {
+            var builder = new Json.Builder();
+            builder.begin_object();
+            builder.set_member_name("title");
+            builder.add_string_value(notification.title);
+            builder.set_member_name("body");
+            builder.add_string_value(notification.body);
+            builder.set_member_name("url");
+            builder.add_string_value(notification.url);
+            builder.end_object();
+            var generator = new Json.Generator();
+            generator.set_root(builder.get_root());
+            return generator.to_data(null);
+        }
+
         // ------------------------------------------------------------------
-        // Enqueue + delivery
+        // Audiences + dispatch (fire-and-forget over Spry.Push)
         // ------------------------------------------------------------------
 
-        /** Queues a notification for every push-subscribed user (block events). */
+        /** Notifies every push-subscribed user (block / goal events). */
         public void notify_global(PushNotification notification) {
-            enqueue_target(new PendingTarget() {
-                global = true,
-                notification = notification
-            });
+            dispatch.begin(null, notification);
         }
 
         /**
-         * Queues a notification for the users holding a "Notify me"
-         * pref for `task_id` (first start / failure / completion).
+         * Notifies the users holding a "Notify me" pref for `task_id`
+         * (first start / failure / completion).
          */
         public void notify_opted_in(int64 task_id, PushNotification notification) {
-            enqueue_target(new PendingTarget() {
-                global = false,
-                task_id = task_id,
-                notification = notification
-            });
-        }
-
-        private void enqueue_target(PendingTarget target) {
-            if (vapid_private_key.length == 0) {
-                return;
-            }
-            queue_lock.lock();
-            pending.push_tail(target);
-            queue_lock.unlock();
-            flush.begin();
+            dispatch.begin(task_id, notification);
         }
 
         /**
-         * Delivers everything queued: resolves pending targets into
-         * per-endpoint jobs (a fresh database scope — subscriptions may
-         * have changed since the event), then sends each job. Failures
-         * stay queued for the next run (the dispatcher's tick calls
-         * this); 410/404 endpoints are pruned instead of retried.
-         * Single-flight: a concurrent call returns immediately while a
-         * flush is in progress.
+         * Resolves the audience and delivers to each user through
+         * Spry's sender. Runs detached from the event: a slow or dead
+         * push service never holds up the caller. A fresh container
+         * scope per dispatch (subscriptions may have changed since the
+         * event; the fresh scope also satisfies the injection-context
+         * rule — nothing here resolves through a bare `inject<>`).
          */
-        public async void flush() {
-            if (flushing) {
-                return;
-            }
-            flushing = true;
-            try {
-                yield resolve_pending();
-                while (true) {
-                    queue_lock.lock();
-                    SendJob? job = queued.pop_head();
-                    queue_lock.unlock();
-                    if (job == null) {
-                        break;
-                    }
-                    bool requeued = yield deliver((!)job);
-                    if (requeued) {
-                        // A failed send goes back for a LATER pass:
-                        // draining in place would burn every attempt in
-                        // one tight loop. The dispatcher tick (or the
-                        // next enqueue's flush) picks the job back up —
-                        // that spacing IS the retry backoff.
-                        break;
-                    }
-                }
-            } finally {
-                flushing = false;
-            }
-        }
-
-        /** Moves pending targets into per-endpoint send jobs. */
-        private async void resolve_pending() {
-            queue_lock.lock();
-            var targets = new GLib.List<PendingTarget>();
-            while (!pending.is_empty()) {
-                targets.append(pending.pop_head());
-            }
-            queue_lock.unlock();
-            if (targets.length() == 0) {
-                return;
-            }
-
+        private async void dispatch(int64? task_id, PushNotification notification) {
             try {
                 var scope = container.create_scope();
                 try {
                     var db = scope.resolve<OrmSession>();
-                    var subscriptions = yield db.query<PushSubscription>().materialise_async();
-                    var jobs = new GLib.Queue<SendJob>();
-                    foreach (var target in targets) {
-                        foreach (var row in subscriptions) {
-                            if (!target.global
-                                    && !(yield pref_held(db, row.user_id, target.task_id))) {
-                                continue;
+                    var pushes = scope.resolve<Spry.Push.PushService>();
+                    var sender = scope.resolve<PushSender>();
+
+                    int64[] users;
+                    if (task_id == null) {
+                        users = yield subscribed_user_ids(scope, db);
+                    } else {
+                        users = yield opted_in_user_ids(db, task_id);
+                    }
+                    string payload = payload_json(notification);
+
+                    int delivered = 0;
+                    int failed = 0;
+                    foreach (int64 user_id in users) {
+                        try {
+                            foreach (var outcome in yield sender.send_to_user(
+                                    pushes, user_id, payload, TTL_SECONDS)) {
+                                if (outcome == DeliveryOutcome.DELIVERED) {
+                                    delivered++;
+                                } else if (outcome != DeliveryOutcome.GONE
+                                        && outcome != DeliveryOutcome.UNAUTHORIZED) {
+                                    // GONE/UNAUTHORIZED rows are already
+                                    // swept by the sender — only genuine
+                                    // failures are worth counting.
+                                    failed++;
+                                }
                             }
-                            jobs.push_tail(new SendJob() {
-                                endpoint = row.endpoint,
-                                p256dh = row.p256dh,
-                                auth = row.auth,
-                                payload = payload_json(target.notification)
-                            });
+                        } catch (Error e) {
+                            failed++;
+                            warning("[ClankerSaddle] Push delivery to user %lld"
+                                + " failed: %s", (long) user_id, e.message);
                         }
                     }
-                    if (jobs.is_empty()) {
-                        return;
-                    }
-                    queue_lock.lock();
-                    while (!jobs.is_empty()) {
-                        queued.push_tail(jobs.pop_head());
+                    if (failed > 0) {
+                        warning("[ClankerSaddle] Push notification %s:"
+                            + " %d/%d user(s) not reached",
+                            notification.title, failed, delivered + failed);
                     }
-                    queue_lock.unlock();
                 } finally {
                     scope.release();
                 }
             } catch (Error e) {
-                warning("[ClankerSaddle] Push target resolution failed: %s", e.message);
-                // Targets stay lost this pass; the event is best-effort.
+                warning("[ClankerSaddle] Push dispatch failed: %s", e.message);
             }
         }
 
-        /** The wire payload JSON (compact, exactly what the SW parses). */
-        private static string payload_json(PushNotification notification) {
-            var builder = new Json.Builder();
-            builder.begin_object();
-            builder.set_member_name("title");
-            builder.add_string_value(notification.title);
-            builder.set_member_name("body");
-            builder.add_string_value(notification.body);
-            builder.set_member_name("url");
-            builder.add_string_value(notification.url);
-            builder.end_object();
-            var generator = new Json.Generator();
-            generator.set_root(builder.get_root());
-            return generator.to_data(null);
-        }
-
-        private enum SendOutcome {
-            DELIVERED,
-            RETRY,
-            GONE
-        }
-
         /**
-         * Applies one job's outcome: DELIVERED drops it, GONE prunes
-         * the subscription row, anything else requeues it for a later
-         * tick. Returns true when the job was requeued — the flush
-         * drain then STOPS (the retry waits for the next tick instead
-         * of burning every attempt back-to-back), which is what makes
-         * MAX_ATTEMPTS a real backoff rather than one hot loop.
+         * DISTINCT user ids holding any Spry push subscription — the
+         * global audience. Read as raw SQL through the scope's
+         * connection (the ORM has no DISTINCT projection).
          */
-        private async bool deliver(SendJob job) {
-            SendOutcome outcome = SendOutcome.RETRY;
-            try {
-                outcome = yield deliver_once(job);
-            } catch (Error e) {
-                outcome = SendOutcome.RETRY;
-            }
-            if (outcome == SendOutcome.DELIVERED) {
-                return false;
-            }
-            if (outcome == SendOutcome.GONE) {
-                yield prune_subscription(job.endpoint);
-                return false;
-            }
-            job.attempts++;
-            if (job.attempts >= MAX_ATTEMPTS) {
-                // Giving up is loud on purpose: a silently dropped
-                // notification looks exactly like a delivered one.
-                warning("[ClankerSaddle] Push to %s gave up after %d attempts"
-                    + " — notification dropped undelivered",
-                    job.endpoint, job.attempts);
-                return false;
+        private static async int64[] subscribed_user_ids(Inversion.Scope scope,
+                OrmSession db) throws Error {
+            var ids = new int64[0];
+            var connection = scope.resolve<Connection>();
+            var rows = yield connection.create_command(
+                "select distinct user_id from spry_push_subscriptions"
+                + " order by user_id").execute_query_async();
+            foreach (var row in rows) {
+                var value = row.get("user_id");
+                int64 parsed = 0;
+                if (value != null && ((!)value).try_get_as<int64?>(out parsed)) {
+                    ids += parsed;
+                }
             }
-            queue_lock.lock();
-            queued.push_tail(job);
-            queue_lock.unlock();
-            return true;
+            return ids;
         }
 
-        /**
-         * The dashboard's "Send test notification" probe (per-browser
-         * bell, shown only when THIS browser is subscribed): one REAL
-         * delivery attempt to each of the user's endpoints through the
-         * same {@link deliver_once} crypto + VAPID path the queue
-         * uses, awaited inline so the action can report per-endpoint
-         * outcomes. The shared queue's flush is single-flight and
-         * fire-and-forget — its outcomes are unobservable from a
-         * request — so the probe resolves and sends synchronously
-         * instead of enqueueing. A refused endpoint is reported rather
-         * than retried here (the queue's own backoff still governs
-         * real events); 404/410 endpoints are pruned exactly as the
-         * queue would, so the probe also heals dead rows on demand.
-         */
-        public async PushTestReport send_test(OrmSession db, int64 user_id) throws Error {
-            var report = new PushTestReport();
-            var jobs = new GLib.List<SendJob>();
-            foreach (var row in yield db.query<PushSubscription>()
-                    .where(expr("user_id == $0", elem<int64?>(user_id)))
+        /** User ids holding a "Notify me" pref for the task. */
+        private static async int64[] opted_in_user_ids(OrmSession db,
+                int64 task_id) throws Error {
+            var ids = new int64[0];
+            foreach (var row in yield db.query<TaskNotifyPref>()
+                    .where(expr("task_id == $0", elem<int64?>(task_id)))
                     .materialise_async()) {
-                jobs.append(new SendJob() {
-                    endpoint = row.endpoint,
-                    p256dh = row.p256dh,
-                    auth = row.auth,
-                    payload = payload_json(test_notification())
-                });
-            }
-            foreach (var job in jobs) {
-                SendOutcome outcome = SendOutcome.RETRY;
-                string problem = "";
-                try {
-                    outcome = yield deliver_once(job);
-                } catch (Error e) {
-                    problem = e.message;
-                }
-                if (outcome == SendOutcome.DELIVERED) {
-                    report.delivered++;
-                    continue;
-                }
-                if (outcome == SendOutcome.GONE) {
-                    report.pruned++;
-                    yield prune_subscription(job.endpoint);
-                    continue;
-                }
-                report.failed++;
-                if (report.first_failure.length == 0) {
-                    report.first_failure = job.last_status > 0
-                        ? @"HTTP $(job.last_status)" : problem;
-                }
+                ids += row.user_id;
             }
-            return report;
+            return ids;
         }
 
-        /**
-         * One Web Push protocol POST: RFC 8291 aes128gcm body, ES256
-         * VAPID JWT in the Authorization header. Transport errors and
-         * 5xx throw (retry); 404/410 report {@link SendOutcome.GONE}
-         * (prune); any OTHER 4xx is a refusal — logged and retried,
-         * never silently delivered; remaining 2xx/3xx counts as
-         * delivered (the push service owns delivery from here).
-         */
-        private async SendOutcome deliver_once(SendJob job) throws Error {
-            uint8[] client_public = base64url_decode(job.p256dh);
-            uint8[] auth_secret = base64url_decode(job.auth);
-            if (client_public.length != 65 || auth_secret.length != 16) {
-                return SendOutcome.GONE;
-            }
-
-            // RFC 8291: ephemeral ECDH -> HKDF -> AES-128-GCM.
-            var eph_priv = new uint8[32];
-            var eph_pub = new uint8[65];
-            if (crypto_p256_generate((uint8*) eph_priv, (uint8*) eph_pub) != 1) {
-                throw new Error(Quark.from_string("clanker-saddle"), 60,
-                    "ephemeral P-256 generation failed");
-            }
-            var secret = new uint8[32];
-            if (crypto_p256_ecdh((uint8*) eph_priv, (uint8*) client_public, (uint8*) secret) != 1) {
-                throw new Error(Quark.from_string("clanker-saddle"), 61,
-                    "ECDH with the subscription key failed");
-            }
-            var ikm = new uint8[48];
-            for (var index = 0; index < 32; index++) {
-                ikm[index] = secret[index];
-            }
-            for (var index = 0; index < 16; index++) {
-                ikm[32 + index] = auth_secret[index];
-            }
-
-            var salt = new uint8[16];
-            if (crypto_random((uint8*) salt, 16) != 1) {
-                throw new Error(Quark.from_string("clanker-saddle"), 62,
-                    "salt generation failed");
-            }
-            // "WebPush: info" || 0x00 || eph_pub || client_pub
-            var context = new uint8[14 + 65 + 65];
-            var label = "WebPush: info".data;
-            for (var index = 0; index < 13; index++) {
-                context[index] = label[index];
-            }
-            context[13] = 0x00;
-            for (var index = 0; index < 65; index++) {
-                context[14 + index] = eph_pub[index];
-                context[14 + 65 + index] = client_public[index];
-            }
-            uint8[] cek = hkdf(salt, ikm, context, 16);
-            uint8[] nonce = hkdf(salt, ikm, context, 12);
-
-            uint8[] plaintext = job.payload.data;
-            var framed = new uint8[plaintext.length + 1];
-            for (var index = 0; index < plaintext.length; index++) {
-                framed[index] = plaintext[index];
-            }
-            framed[plaintext.length] = 0x02; // last-record delimiter
-            var ciphertext = new uint8[framed.length + 16];
-            size_t written = 0;
-            if (crypto_aes128gcm_encrypt((uint8*) cek, (uint8*) nonce, (uint8*) framed,
-                    framed.length, (uint8*) ciphertext, out written) != 1) {
-                throw new Error(Quark.from_string("clanker-saddle"), 63,
-                    "payload encryption failed");
-            }
-
-            var body = new uint8[16 + 4 + 1 + 65 + written];
-            for (var index = 0; index < 16; index++) {
-                body[index] = salt[index];
-            }
-            int64 record_size = plaintext.length + 17;
-            body[16] = (uint8) ((record_size >> 24) & 0xff);
-            body[17] = (uint8) ((record_size >> 16) & 0xff);
-            body[18] = (uint8) ((record_size >> 8) & 0xff);
-            body[19] = (uint8) (record_size & 0xff);
-            body[20] = 65;
-            for (var index = 0; index < 65; index++) {
-                body[21 + index] = eph_pub[index];
-            }
-            for (var index = 0; index < written; index++) {
-                body[21 + 65 + index] = ciphertext[index];
-            }
-
-            var message = new Soup.Message("POST", job.endpoint);
-            message.set_request_body_from_bytes("application/octet-stream",
-                new Bytes(body));
-            message.request_headers.append("TTL", "60");
-            message.request_headers.append("Urgency", "high");
-            // RFC 8291 section 4: every aes128gcm body MUST carry
-            // Content-Encoding or the push service rejects with 400.
-            message.request_headers.append("Content-Encoding", "aes128gcm");
-            message.request_headers.append("Authorization",
-                @"vapid t=$(vapid_jwt(job.endpoint)), k=$(vapid_public_key)");
-            try {
-                yield http.send_and_read_async(message, Priority.DEFAULT, null);
-            } catch (Error e) {
-                throw new Error(Quark.from_string("clanker-saddle"), 64,
-                    @"push POST failed: $(e.message)");
-            }
-            if (message.status_code == 404 || message.status_code == 410) {
-                return SendOutcome.GONE;
-            }
-            if (message.status_code >= 500) {
-                job.last_status = (int) message.status_code;
-                throw new Error(Quark.from_string("clanker-saddle"), 65,
-                    @"push service answered HTTP $(message.status_code)");
-            }
-            if (message.status_code >= 400) {
-                // 401/403 (VAPID rejected) and every other 4xx that is
-                // not the terminal 404/410 handled above: the push
-                // service did NOT accept the message. Truthful
-                // delivery — log it with the endpoint and status,
-                // retry on later ticks, give up loudly after
-                // MAX_ATTEMPTS, and never count it as delivered. The
-                // subscription row stays: the endpoint may recover
-                // (e.g. a VAPID key rotation heals on a later send).
-                job.last_status = (int) message.status_code;
-                warning("[ClankerSaddle] Push endpoint %s refused delivery:"
-                    + " HTTP %d — not delivered, retrying",
-                    job.endpoint, (int) message.status_code);
-                return SendOutcome.RETRY;
-            }
-            return SendOutcome.DELIVERED;
-        }
+        // ------------------------------------------------------------------
+        // The bell's test probe
+        // ------------------------------------------------------------------
 
         /**
-         * The ES256 VAPID JWT for an endpoint: aud = its origin, exp =
-         * now + 12h, sub = a mailto contact (`SADDLE_VAPID_SUBJECT`
-         * overrides), signature = raw r||s.
+         * The dashboard's "Send test notification" probe (per-browser
+         * bell, shown only when THIS browser is subscribed): one REAL
+         * delivery through the full RFC 8291 + VAPID path — Spry's
+         * sender — awaited inline so the action can report
+         * per-endpoint outcomes. The sender's own semantics hold:
+         * dead endpoints (404/410/401/403) are swept, transport
+         * failures already retried twice inline, everything surfaces
+         * in the toast. `db` is unused beyond signature stability —
+         * the sender resolves its own store.
          */
-        private static string vapid_jwt(string endpoint) {
-            string origin = endpoint;
-            int scheme = endpoint.index_of("://");
-            if (scheme > 0) {
-                int path = endpoint.index_of("/", scheme + 3);
-                origin = path > 0 ? endpoint.substring(0, path) : endpoint;
-            }
-            string subject = Environment.get_variable("SADDLE_VAPID_SUBJECT")
-                ?? "mailto:clanker-saddle@localhost";
-            string header = "{\"typ\":\"JWT\",\"alg\":\"ES256\"}";
-            string claims = "{\"aud\":\"" + origin + "\",\"exp\":"
-                + (new DateTime.now_local().to_unix() + 43200).to_string()
-                + ",\"sub\":\"" + subject + "\"}";
-            string header_b64 = base64url_encode(header.data);
-            string claims_b64 = base64url_encode(claims.data);
-            string signing_input = header_b64 + "." + claims_b64;
-
-            var checksum = new Checksum(ChecksumType.SHA256);
-            size_t digest_len = 32;
-            var digest = new uint8[32];
-            checksum.update(signing_input.data, signing_input.length);
-            checksum.get_digest(digest, ref digest_len);
-
-            uint8[] priv = base64url_decode(vapid_private_key);
-            var signature = new uint8[64];
-            if (crypto_p256_sign((uint8*) priv, (uint8*) digest, (uint8*) signature) != 1) {
-                warning("[ClankerSaddle] VAPID JWT signing failed");
-                return "";
-            }
-            return signing_input + "." + base64url_encode(signature);
-        }
-
-        /** Deletes subscriptions whose endpoint went 404/410 Gone. */
-        private async void prune_subscription(string endpoint) {
+        public async PushTestReport send_test(OrmSession db, int64 user_id) throws Error {
+            var report = new PushTestReport();
             var scope = container.create_scope();
             try {
-                var db = scope.resolve<OrmSession>();
-                foreach (var row in yield db.query<PushSubscription>()
-                        .where(expr("endpoint == $0", elem(endpoint)))
-                        .materialise_async()) {
-                    yield db.delete_async<PushSubscription>(row);
+                var pushes = scope.resolve<Spry.Push.PushService>();
+                var sender = scope.resolve<PushSender>();
+                string payload = payload_json(test_notification());
+                foreach (var outcome in yield sender.send_to_user(pushes,
+                        user_id, payload, TTL_SECONDS)) {
+                    if (outcome == DeliveryOutcome.DELIVERED) {
+                        report.delivered++;
+                    } else if (outcome == DeliveryOutcome.GONE
+                            || outcome == DeliveryOutcome.UNAUTHORIZED) {
+                        report.pruned++;
+                    } else {
+                        report.failed++;
+                        if (report.first_failure.length == 0) {
+                            report.first_failure = outcome == DeliveryOutcome.SKIPPED
+                                ? "VAPID keys are not configured — run `spry keys`"
+                                : "push service did not accept the message";
+                        }
+                    }
                 }
-                print("[ClankerSaddle] Pruned gone push subscription %s\n",
-                    endpoint.substring(0, int.min(endpoint.length, 72)));
-            } catch (Error e) {
-                warning("[ClankerSaddle] Pruning push subscription failed: %s", e.message);
             } finally {
                 scope.release();
             }
+            return report;
         }
 
         // ------------------------------------------------------------------
-        // Subscriptions + notify prefs (caller's database scope)
+        // Subscription views (caller's database scope)
         // ------------------------------------------------------------------
 
         /**
@@ -777,28 +383,20 @@ namespace ClankerSaddle.Services {
         }
 
         /**
-         * Stores (or replaces — the endpoint is unique per browser) a
-         * user's subscription.
+         * Stores (or replaces — the endpoint is Spry's subscription
+         * identity) a user's subscription through the Spry store.
+         * `db` is the caller's session (same database; the store uses
+         * its own — WAL: readers never block writers).
          */
         public async void upsert_subscription(OrmSession db, int64 user_id,
                 string endpoint, string p256dh, string auth) throws Error {
-            var existing = yield db.query<PushSubscription>()
-                .where(expr("endpoint == $0", elem(endpoint)))
-                .first_async();
-            if (existing != null) {
-                ((!)existing).user_id = user_id;
-                ((!)existing).p256dh = p256dh;
-                ((!)existing).auth = auth;
-                yield db.update_async<PushSubscription>((!)existing);
-                return;
+            var scope = container.create_scope();
+            try {
+                yield scope.resolve<Spry.Push.PushService>()
+                    .subscribe(user_id, endpoint, p256dh, auth);
+            } finally {
+                scope.release();
             }
-            yield db.insert_async<PushSubscription>(new PushSubscription() {
-                user_id = user_id,
-                endpoint = endpoint,
-                p256dh = p256dh,
-                auth = auth,
-                created_at = new DateTime.now_local()
-            });
         }
 
         /**
@@ -807,11 +405,12 @@ namespace ClankerSaddle.Services {
          */
         public async void remove_subscription(OrmSession db, int64 user_id,
                 string endpoint) throws Error {
-            foreach (var row in yield db.query<PushSubscription>()
-                    .where(expr("endpoint == $0 && user_id == $1",
-                        elem(endpoint), elem<int64?>(user_id)))
-                    .materialise_async()) {
-                yield db.delete_async<PushSubscription>(row);
+            var scope = container.create_scope();
+            try {
+                yield scope.resolve<Spry.Push.PushService>()
+                    .unsubscribe(user_id, endpoint);
+            } finally {
+                scope.release();
             }
         }
 
@@ -822,12 +421,13 @@ namespace ClankerSaddle.Services {
          * client-side against the browser's own subscription.
          */
         public async bool user_subscribed(OrmSession db, int64 user_id) throws Error {
-            foreach (var row in yield db.query<PushSubscription>()
-                    .where(expr("user_id == $0", elem<int64?>(user_id)))
-                    .materialise_async()) {
-                return true;
+            var scope = container.create_scope();
+            try {
+                return (yield scope.resolve<Spry.Push.PushService>()
+                        .subscriptions_for(user_id)).count() > 0;
+            } finally {
+                scope.release();
             }
-            return false;
         }
 
         /**
@@ -839,14 +439,29 @@ namespace ClankerSaddle.Services {
          * up in the list: present => THIS browser is subscribed (show
          * "Turn off"), absent => "Enable" (+ a count of the others).
          */
-        public async string[] user_endpoint_fingerprints(OrmSession db, int64 user_id) throws Error {
+        public async string[] user_endpoint_fingerprints(OrmSession db,
+                int64 user_id) throws Error {
             var fingerprints = new string[0];
-            foreach (var row in yield db.query<PushSubscription>()
-                    .where(expr("user_id == $0", elem<int64?>(user_id)))
-                    .order_by_desc(expr("created_at"))
-                    .order_by_desc(expr("id"))
-                    .materialise_async()) {
-                fingerprints += endpoint_fingerprint(row.endpoint);
+            var scope = container.create_scope();
+            try {
+                var rows = yield scope.resolve<Spry.Push.PushService>()
+                    .subscriptions_for(user_id);
+                // Spry's store order is unspecified: sort by creation,
+                // then id, newest first.
+                var by_recency = new GLib.List<Spry.Push.PushSubscriptionEntity>();
+                foreach (var row in rows) {
+                    by_recency.append(row);
+                }
+                by_recency.sort((a, b) => {
+                    int created_cmp = b.created.compare(a.created);
+                    return created_cmp != 0 ? created_cmp
+                        : (a.id > b.id ? -1 : (a.id < b.id ? 1 : 0));
+                });
+                foreach (var row in by_recency) {
+                    fingerprints += endpoint_fingerprint(row.endpoint);
+                }
+            } finally {
+                scope.release();
             }
             return fingerprints;
         }
@@ -871,6 +486,10 @@ namespace ClankerSaddle.Services {
             return builder.str;
         }
 
+        // ------------------------------------------------------------------
+        // Notify prefs (pure app domain, caller's database scope)
+        // ------------------------------------------------------------------
+
         /** True when the user holds a "Notify me" pref for the task. */
         public async bool has_notify_pref(OrmSession db, int64 user_id,
                 int64 task_id) throws Error {
@@ -910,60 +529,6 @@ namespace ClankerSaddle.Services {
         // Primitives
         // ------------------------------------------------------------------
 
-        /** HKDF (RFC 5869, SHA-256): extract(salt, ikm) then expand(info). */
-        private static uint8[] hkdf(uint8[] salt, uint8[] ikm, uint8[] info,
-                size_t length) {
-            var extract_hmac = new Hmac(ChecksumType.SHA256, salt);
-            extract_hmac.update(ikm);
-            size_t prk_len = 32;
-            var prk = new uint8[32];
-            extract_hmac.get_digest(prk, ref prk_len);
-
-            var output = new uint8[length];
-            var counter = new uint8[1];
-            uint8[] accumulator = new uint8[0];
-            size_t produced = 0;
-            uint8 block_index = 1;
-            while (produced < length) {
-                var expand_hmac = new Hmac(ChecksumType.SHA256, prk);
-                expand_hmac.update(accumulator);
-                expand_hmac.update(info);
-                counter[0] = block_index;
-                expand_hmac.update(counter);
-                size_t block_len = 32;
-                var block = new uint8[32];
-                expand_hmac.get_digest(block, ref block_len);
-                size_t take = 32;
-                if (length - produced < 32) {
-                    take = length - produced;
-                }
-                for (var index = 0; index < take; index++) {
-                    output[produced + index] = block[index];
-                }
-                produced += take;
-                accumulator = block;
-                block_index++;
-            }
-            return output;
-        }
-
-        /** Standard base64 -> base64url (unpadded). */
-        public static string base64url_encode(uint8[] data) {
-            string encoded = Base64.encode(data);
-            var builder = new StringBuilder();
-            for (var index = 0; index < encoded.length; index++) {
-                char character = encoded[index];
-                if (character == '+') {
-                    builder.append_c('-');
-                } else if (character == '/') {
-                    builder.append_c('_');
-                } else if (character != '=') {
-                    builder.append_c(character);
-                }
-            }
-            return builder.str;
-        }
-
         /** base64url (padded or not) -> bytes; junk decodes empty. */
         public static uint8[] base64url_decode(string text) {
             var normalised = new StringBuilder();

+ 3 - 3
src/Services/TaskService.vala

@@ -895,7 +895,7 @@ namespace ClankerSaddle.Services {
             raised_payload.set_string_member("text", @"$(block.block_type) raised: $(block.question)");
             yield events.append(EVENT_STATE_CHANGE, EVENT_ACTOR_SYSTEM, EVENT_SCOPE_TASK,
                 task_id, raised_payload);
-            // Web Push (plan 11 task E): every raised block notifies all
+            // Web Push: every raised block notifies all
             // push-subscribed users globally — the goal agent's mirrored
             // blocks stay quiet (the goal-level block owns the
             // notification with the goal's title and URL).
@@ -1287,7 +1287,7 @@ namespace ClankerSaddle.Services {
 
             yield release_task(task_id, outcome, false);
             yield pulse.bump(task_id, "task completed");
-            // Web Push (plan 11 task E): completion notifies the users
+            // Web Push: completion notifies the users
             // holding a "Notify me" pref for this task.
             push.notify_opted_in(task_id, PushService.lifecycle_notification(
                 ((!)task).title, task_id, "completed", summary));
@@ -1362,7 +1362,7 @@ namespace ClankerSaddle.Services {
 
             yield release_task(task_id, OUTCOME_FAILED, false);
             yield pulse.bump(task_id, "task failed");
-            // Web Push (plan 11 task E): an agent-reported failure
+            // Web Push: an agent-reported failure
             // notifies the opted-in users (a human cancellation does
             // not — the cancellator already knows).
             push.notify_opted_in(task_id, PushService.lifecycle_notification(

+ 3 - 3
src/actions/PushActions.vala

@@ -8,7 +8,7 @@ namespace ClankerSaddle {
 
     /**
      * Stores the browser's Web Push subscription for the signed-in
-     * user (plan 11 task E): `endpoint`, `p256dh` and `auth` arrive
+     * user: `endpoint`, `p256dh` and `auth` arrive
      * from the dashboard bell's subscribe flow
      * (`pushManager.subscribe` + the VAPID key from the page state).
      * Validated per {@link PushService.validate_subscription}; the
@@ -78,8 +78,8 @@ namespace ClankerSaddle {
     }
 
     /**
-     * Flips the signed-in user's per-task "Notify me" pref (plan 11
-     * task E): opted-in users receive the task's first start, failure
+     * Flips the signed-in user's per-task "Notify me" pref: opted-in
+     * users receive the task's first start, failure
      * and completion on top of the global block events every
      * push-subscribed user gets.
      */

+ 5 - 3
src/entrypoints/HomeEntrypoint.vala

@@ -419,14 +419,16 @@ namespace ClankerSaddle {
             // per-instance and the endpoint objects do the same.
             var canon = new Services.Canon();
             pub.canon_texts = canon.texts().length;
-            // Web Push bell state (plan 11 task E; per-browser since
+            // Web Push bell state (per-browser since
             // the user-global flag lied to second browsers): the VAPID
-            // key the browser needs, whether this user subscribed
+            // key the browser needs (Spry's sender, `spry keys`-owned —
+            // "" when unkeyed), whether this user subscribed
             // anywhere (convenience), and this user's endpoint
             // FINGERPRINTS — the client compares its own subscription's
             // fingerprint against the list, so each browser shows its
             // own truth.
-            pub.push_vapid_key = PushService.vapid_public_key;
+            pub.push_vapid_key = push != null
+                ? ((!)push).vapid_public_key() : "";
             // Not a DashboardPublic property: GObjectMapping cannot
             // serialise array-shaped values, so the fingerprint list
             // rides the props dictionary like every other list here.

+ 7 - 5
src/entrypoints/ProfileEntrypoint.vala

@@ -112,11 +112,13 @@ namespace ClankerSaddle {
             // the dashboard's bell strip derives its per-browser verdict
             // from, so /profile owns the preference UI while the
             // dashboard keeps only the compact strip. Reuses the
-            // EXISTING dashboard push actions — PushService untouched.
-            // The push service and db session arrive as parameters
-            // (SetThemeAction's scope may carry them; a static method
-            // cannot Inversion.inject — the context stack is per-request).
-            view.push_vapid_key = PushService.vapid_public_key;
+            // EXISTING dashboard push actions. The push service and db
+            // session arrive as parameters (SetThemeAction's scope may
+            // carry them; a static method cannot Inversion.inject — the
+            // context stack is per-request). The VAPID key comes from
+            // Spry's sender via the service ("" when unkeyed).
+            view.push_vapid_key = push != null
+                ? ((!)push).vapid_public_key() : "";
             view.push_subscribe = action_registry.author<PushSubscribeAction>();
             view.push_unsubscribe = action_registry.author<PushUnsubscribeAction>();
             view.push_test = action_registry.author<PushTestAction>();

+ 1 - 1
src/entrypoints/TaskDetailEntrypoint.vala

@@ -268,7 +268,7 @@ namespace ClankerSaddle {
             pub.refresh = actions.author_private<RefreshTaskDataAction, TaskActionPrivate>(sealed, "GET");
             pub.attach_rule = actions.author_private<AttachTaskRuleAction, TaskActionPrivate>(sealed);
             pub.attach_capability = actions.author_private<AttachTaskCapabilityAction, TaskActionPrivate>(sealed);
-            // Web Push notify-me state (plan 11 task E): the signed-in
+            // Web Push notify-me state: the signed-in
             // user's per-task opt-in for start/failure/completion.
             if (viewer_id > 0) {
                 pub.notify_me = yield push.has_notify_pref(db, viewer_id, task.id);

+ 6 - 9
src/main.vala

@@ -53,6 +53,7 @@ int main(string[] args) {
         db.add_migration<ClankerSaddle.Data.Migrations.M0009_TurnCapPark>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0010_ModelConfig>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0011_UserPreferences>();
+        db.add_migration<ClankerSaddle.Data.Migrations.M0012_ProviderPushSubscriptions>();
         db.add_entity<Project>(Project.entity_mapping);
         db.add_entity<ProjectLink>(ProjectLink.entity_mapping);
         db.add_entity<TaskEntity>(TaskEntity.entity_mapping);
@@ -70,7 +71,11 @@ int main(string[] args) {
         db.add_entity<Capability>(Capability.entity_mapping);
         db.add_entity<SaddleCapability>(SaddleCapability.entity_mapping);
         db.add_entity<TaskCapability>(TaskCapability.entity_mapping);
-        db.add_entity<PushSubscription>(PushSubscription.entity_mapping);
+        // Web Push storage/delivery is Spry's: SpryModule
+        // registers the spry_push_subscriptions entity, migrations and
+        // the singleton PushSender. The app's registration below adds
+        // OUR push entity to the same OrmSession so raw queries see it.
+        db.add_entity<Spry.Push.PushSubscriptionEntity>(Spry.Push.PushSubscriptionEntity.entity_mapping);
         db.add_entity<TaskNotifyPref>(TaskNotifyPref.entity_mapping);
         db.add_entity<Goal>(Goal.entity_mapping);
         db.add_entity<GoalLink>(GoalLink.entity_mapping);
@@ -109,14 +114,6 @@ int main(string[] args) {
         application.add_singleton<AdvisorService>();
         application.add_singleton<AdvisorCallback>();
 
-        // Web Push (plan 11 task E): the VAPID keypair loads or
-        // generates + persists BEFORE the server accepts requests, so
-        // every subscriber sees the same public key from the first
-        // page render.
-        var key_scope = application.container.create_scope();
-        key_scope.resolve<PushService>().ensure_keys_from_config();
-        key_scope.release();
-
         var statum = application.configure_with<StatumConfigurator>();
         statum.global<ClankerSaddle.Services.PulsePublic>(StatePulse.SLOT_TYPE,
             new ClankerSaddle.Services.PulsePublic());

+ 12 - 2
src/pages/app.js

@@ -702,16 +702,26 @@
                             if (!card) return;
                             var here = mine !== null && list.indexOf(mine) !== -1;
                             var others = otherBrowserCount();
+                            // Without a VAPID key (the app booted unkeyed and
+                            // `spry keys` has not run) there is nothing to
+                            // subscribe WITH: hide Enable and say why. An
+                            // existing subscription can still be turned off,
+                            // and the test probe reports the real reason.
+                            var keyed = !!state().push_vapid_key;
                             var onBtn = card.querySelector('[data-bell-on]');
                             var offBtn = card.querySelector('[data-bell-off]');
                             var help = card.querySelector('[data-bell-help]');
                             var testBtn = card.querySelector('[data-push-test]');
                             hereNow = here;
-                            if (onBtn) onBtn.hidden = here;
+                            if (onBtn) onBtn.hidden = here || !keyed;
                             if (offBtn) offBtn.hidden = !here;
                             if (testBtn) testBtn.hidden = !here;
                             if (help) {
-                                if (here) {
+                                if (!keyed) {
+                                    help.textContent = '\u2014 notifications are not configured'
+                                        + ' on this server yet (no push keys \u2014 the operator'
+                                        + ' needs to run spry keys)';
+                                } else if (here) {
                                     help.textContent = others > 0
                                         ? '\u2014 this browser receives question, RFC, review and waiting-on-drafts alerts; '
                                           + others + ' other browser(s) are also subscribed; "Notify me" per task adds its start, failure and completion'

+ 3 - 3
src/pages/home.html

@@ -7,8 +7,8 @@
     // Live dashboard: the pulse subscription (see the entrypoint)
     // re-runs the sealed refresh action whenever task or saddle state
     // changes, so progress, blocks and review requests stream in.
-    // The notification bell drives the Web Push subscribe flow (plan
-    // 11 task E) - the shared engine lives in app.js
+    // The notification bell drives the Web Push subscribe flow - the
+    // shared engine lives in app.js
     // (window.initPushBell, goal 41 task 3: the dashboard's compact
     // rail strip and /profile's settings card run the same code).
     if (window.statum) {
@@ -244,7 +244,7 @@
                         <p><a href="/saddles">Manage saddles</a></p>
                     </section>
 
-                    <!-- The notification bell (plan 11 task E; per-browser
+                    <!-- The notification bell (per-browser
                          since the user-global flag lied to second
                          browsers): THIS browser's own subscription decides
                          Enable vs Turn off (the JS fingerprints the

+ 6 - 5
test/bundles/pages.py

@@ -948,14 +948,15 @@ def run(ctx):
         "select name from sqlite_master"
         " where type = 'index' and tbl_name = 'events'").fetchall()}
     conn.close()
-    check([s[0] for s in serials] == [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
-          "the clanker-saddle namespace carries exactly serials 1 through 11"
+    check([s[0] for s in serials] == [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
+          "the clanker-saddle namespace carries exactly serials 1 through 12"
           " (a fresh boot ran M0001 then the M0002 no-op then M0003,"
           " M0004 understandings, M0005 discoveries, M0006 advisor,"
           " M0007 the parking guard, M0008 answer-wake, M0009 the"
-          " turn-cap park, M0010 the model config and M0011 the user"
-          " preferences)")
-    check(len(serials) == 11,
+          " turn-cap park, M0010 the model config, M0011 the user"
+          " preferences and M0012 the move of push subscriptions into"
+          " Spry's store)")
+    check(len(serials) == 12,
           "no duplicate migration rows (each guarded up() applied once)")
     check({"idx_events_scope", "idx_events_terminal"} <= event_indexes,
           "M0003's event reader indexes exist (fresh boot)")

+ 140 - 86
test/bundles/push.py

@@ -1,15 +1,17 @@
-"""The push bundle (moved verbatim from the pre-split web_stack_test.py;
-see test/harness.py for the shared machinery and the runner)."""
+"""The push bundle: Web Push over `Spry.Push` semantics (see
+test/harness.py for the shared machinery and the runner)."""
 from harness import *  # noqa: F401,F403 — the shared wire helpers
 
 
 # =============================================================================
-# Bundle: push — plan 11 task E: real Web Push over VAPID. VAPID
-# persistence, the service worker, subscription CRUD + validation,
-# notify-me prefs, the block/lifecycle dispatch matrix (global vs
-# opted-in), 410 pruning, retry-next-tick delivery and protocol-level
-# payload verification (RFC 8291 decryption + ES256 JWT) against the
-# fake push service.
+# Bundle: push — real Web Push over VAPID, Spry.Push underneath. The store
+# (spry_push_subscriptions), the RFC 8291/8292 crypto and the delivery POST
+# are Spry's (inline 2-attempt/250ms retry, 401/403/404/410 sweeping); the
+# payloads, audiences, notify-me prefs and the bell UX stay the app's.
+# Subscription CRUD + validation, the block/lifecycle dispatch matrix
+# (global vs opted-in), sweeping, inline retry, the send_test probe and
+# protocol-level payload verification (RFC 8291 decryption + ES256 JWT)
+# against the fake push service.
 # =============================================================================
 
 def run(ctx):
@@ -19,35 +21,39 @@ def run(ctx):
         conn = db()
         try:
             return conn.execute(
-                "select count(*) from push_subscriptions where endpoint = ?",
+                "select count(*) from spry_push_subscriptions where endpoint = ?",
                 (endpoint,)).fetchone()[0]
         finally:
             conn.close()
 
-    print("== task E: service worker asset ==")
+    print("== service worker asset ==")
     status, sw = http("GET", "/sw.js", timeout=15)
     check(status == 200 and "addEventListener('push'" in sw
           and "notificationclick" in sw and "clients.openWindow" in sw,
           "the service worker serves at the origin root with push + click routing")
 
-    print("== task E: VAPID keypair generation + persistence ==")
+    print("== VAPID keys from the \"spry\" config section ==")
+    # Spry owns the keys: `spry keys` writes the "spry" section the app
+    # reads — the app no longer generates or persists key material.
     config = json.load(open(os.path.join(WORK, "web-config.json")))
-    vapid = config.get("vapid") or {}
-    check(bool(vapid.get("public_key")) and bool(vapid.get("private_key")),
-          "the VAPID keypair persists into web-config.json's vapid section")
+    spry = config.get("spry") or {}
+    check(bool(spry.get("vapid_public_key")) and bool(spry.get("vapid_private_key")),
+          "the VAPID keypair lives in web-config.json's spry section")
+    check("vapid" not in config,
+          "the dead boot-time vapid section is gone")
     vapid_private_key = ec.derive_private_key(
-        int.from_bytes(b64url_dec(vapid["private_key"]), "big"), ec.SECP256R1())
+        int.from_bytes(b64url_dec(spry["vapid_private_key"]), "big"), ec.SECP256R1())
     derived_public = vapid_private_key.public_key().public_bytes(
         Encoding.X962, PublicFormat.UncompressedPoint)
-    check(b64url_dec(vapid["public_key"]) == derived_public,
-          "the persisted public key matches the private scalar (a valid P-256 pair)")
+    check(b64url_dec(spry["vapid_public_key"]) == derived_public,
+          "the configured public key matches the private scalar (a valid P-256 pair)")
     dash = slot_public(auth, "/", "dashboard")
-    check(dash.get("push_vapid_key") == vapid["public_key"],
-          "the dashboard exposes the same VAPID key to subscribers")
+    check(dash.get("push_vapid_key") == spry["vapid_public_key"],
+          "the dashboard exposes the spry section's VAPID key to subscribers")
     check(dash.get("push_subscribed") is False and bool(dash.get("push_subscribe")),
           "the bell starts unsubscribed and carries the subscribe action")
 
-    print("== task E: subscription CRUD + auth validation ==")
+    print("== subscription CRUD + auth validation ==")
     browser = ec.generate_private_key(ec.SECP256R1())
     browser_public = browser.public_key().public_bytes(
         Encoding.X962, PublicFormat.UncompressedPoint)
@@ -72,26 +78,30 @@ def run(ctx):
     act(auth, dash["push_subscribe"],
         {"endpoint": endpoint_main, **subscribe_fields})
     conn = db()
-    rows = conn.execute("select user_id, p256dh, auth from push_subscriptions"
+    rows = conn.execute("select user_id, p256dh, auth from spry_push_subscriptions"
                         " where endpoint = ?", (endpoint_main,)).fetchall()
     admin_id = conn.execute("select id from spry_users where username = 'admin'"
                             ).fetchone()[0]
+    stale = conn.execute("select count(*) from sqlite_master where name ="
+                         " 'push_subscriptions'").fetchone()[0]
     conn.close()
     check(len(rows) == 1 and rows[0][0] == admin_id,
           "a valid subscription stores against the signed-in user")
-    # Re-subscribing the same endpoint replaces (never duplicates).
+    check(stale == 0, "the old push_subscriptions table is gone (fresh database)")
+    # Re-subscribing the same endpoint replaces (never duplicates) — the
+    # endpoint is Spry's subscription identity.
     rotated_auth = os.urandom(16)
     act(auth, dash["push_subscribe"],
         {"endpoint": endpoint_main, "p256dh": subscribe_fields["p256dh"],
          "auth": b64url(rotated_auth)})
     conn = db()
-    rows = conn.execute("select auth from push_subscriptions where endpoint = ?",
+    rows = conn.execute("select auth from spry_push_subscriptions where endpoint = ?",
                         (endpoint_main,)).fetchall()
     conn.close()
     check(len(rows) == 1 and rows[0][0] == b64url(rotated_auth),
           "re-subscribing an endpoint replaces its keys in place")
 
-    print("== task E: notify-me pref persistence ==")
+    print("== notify-me pref persistence ==")
     act(auth, slot_public(auth, "/tasks", "tasks")["create"],
         {"project_id": "1", "title": "Push pref task", "priority": "5"})
     pref_task = task_id_of("Push pref task")
@@ -112,7 +122,7 @@ def run(ctx):
     check(task_state(auth, pref_task).get("notify_me") is False,
           "the task slot reflects the opt-out")
 
-    print("== task E: dispatch matrix — global blocks vs opted-in lifecycle ==")
+    print("== dispatch matrix — global blocks vs opted-in lifecycle ==")
     # Two "browsers" for admin (/main, /gone) and one for another user
     # (id 999, rows seeded directly — push targeting is per user).
     endpoint_gone = push.url("/gone")
@@ -125,8 +135,9 @@ def run(ctx):
     other_auth = os.urandom(16)
     endpoint_other = push.url("/other")
     conn = db()
-    conn.execute("insert into push_subscriptions (user_id, endpoint, p256dh, auth,"
-                 " created_at) values (999, ?, ?, ?, strftime('%s','now'))",
+    conn.execute("insert into spry_push_subscriptions (user_id, endpoint, p256dh,"
+                 " auth, created) values (999, ?, ?, ?,"
+                 " strftime('%s','now'))",
                  (endpoint_other, b64url(other_public), b64url(other_auth)))
     conn.commit()
     conn.close()
@@ -145,7 +156,6 @@ def run(ctx):
     check(wait_for(lambda: fake.running_task == task1 or False, timeout=30,
                    what="task 1 assignment") is not None,
           "task 1 dispatches to the fake daemon")
-    task1_session = fake.session_id
     check(wait_for(lambda: len(push.hits("/main")) > main_before, timeout=15,
                    what="first-start push to the opted-in admin") is not None,
           "first start notifies the opted-in user's subscription")
@@ -166,14 +176,13 @@ def run(ctx):
     check(len(push.hits("/other")) == other_before,
           "completion stays with the opted-in user only")
     check(wait_for(lambda: subscription_count(endpoint_gone) == 0, timeout=15,
-                   what="410 endpoint pruned") is not None,
-          "a 410 Gone endpoint is pruned from push_subscriptions")
-
-    # Block events go to EVERY push-subscribed user globally. The
-    # poll-era /update raise is gone: a live (non-terminal) host task
-    # with an open session on the fake carries the block through the
-    # results push — task1 is done and terminal pushes are consumed
-    # silently, so the host is its own fixture row.
+                   what="410 endpoint swept") is not None,
+          "a 410 Gone endpoint is swept from spry_push_subscriptions")
+
+    # Block events go to EVERY push-subscribed user globally. A live
+    # (non-terminal) host task with an open session on the fake carries
+    # the block through the results push — task1 is done and terminal
+    # pushes are consumed silently, so the host is its own fixture row.
     conn = db()
     conn.execute("insert into tasks (project_id, title, status, priority,"
                  " created_at) values (1, 'Push block host', 'working', 5,"
@@ -205,13 +214,16 @@ def run(ctx):
           and payload["url"] == "/tasks/%d" % push_host,
           "the block payload carries task title + kind, question and click URL (%s)"
           % payload)
-    ciphertext_len = len(block_hit["body"]) - (21 + 65)
     envelope = block_hit["body"]
     record_size = int.from_bytes(envelope[16:20], "big")
-    # rs = plaintext + delimiter (1) + tag (16) == ciphertext-with-tag len.
-    check(record_size == ciphertext_len,
-          "the RFC 8291 envelope record size covers payload + delimiter + tag")
-    claims = verify_vapid(block_hit, vapid["public_key"], vapid["private_key"])
+    # Spry/Locksmith's aes128gcm envelope declares the fixed 4096 max
+    # record size (RFC 8291 §4.2) — the record itself is payload +
+    # delimiter + 16-byte tag, as long as the ciphertext.
+    ciphertext_len = len(envelope) - (21 + 65)
+    check(record_size == 4096 and ciphertext_len > 16,
+          "the RFC 8291 envelope declares Spry's fixed 4096 record size"
+          " over a well-formed record")
+    claims = verify_vapid(block_hit, spry["vapid_public_key"], spry["vapid_private_key"])
     check(claims.get("aud") == "http://127.0.0.1:%d" % push.port
           and claims.get("sub", "").startswith("mailto:")
           and claims.get("exp", 0) > time.time(),
@@ -224,10 +236,10 @@ def run(ctx):
     # 400 regression).
     check(block_hit["headers"].get("content-encoding") == "aes128gcm",
           "the push POST carries Content-Encoding: aes128gcm (RFC 8291)")
-    check(block_hit["headers"].get("urgency") == "high"
+    check(block_hit["headers"].get("urgency") == "normal"
           and block_hit["headers"].get("authorization", "")
           .startswith("vapid t="),
-          "the push POST carries Urgency and the VAPID Authorization")
+          "the push POST carries Spry's Urgency and the VAPID Authorization")
 
     # Task 2: only the OTHER user opted in — start + failure reach them
     # alone. Baseline again before the enqueue (first start fires at
@@ -251,7 +263,7 @@ def run(ctx):
                    or False, timeout=30, what="task 2 failed") is not None,
           "task 2 fails")
     check(wait_for(lambda: len(push.hits("/other")) >= other_before2 + 2,
-                   timeout=15, what="opted-in user's started+failed pushes"),
+                   timeout=15, what="opted-in user's started+failed pushes") is not None,
           "the opted-in user gets first start AND failure for the task")
     started_hit = [hit for hit in push.hits("/other")[other_before2:]][0]
     _, started_payload = decrypt_webpush(started_hit, other_browser,
@@ -266,7 +278,10 @@ def run(ctx):
     check(len(push.hits("/main")) == main_before2,
           "an agent failure never reaches users without the pref")
 
-    print("== task E: retry next tick (best-effort delivery) ==")
+    print("== inline retry (Spry's bounded delivery) ==")
+    # Spry's sender retries INLINE: two attempts, 250ms apart, then
+    # classifies. One transient 503 therefore lands on the second
+    # attempt moments later — no dispatcher tick, no queue row.
     endpoint_flaky = push.url("/flaky")
     act(auth, dash["push_subscribe"],
         {"endpoint": endpoint_flaky, **subscribe_fields})
@@ -275,12 +290,44 @@ def run(ctx):
     raise_global_block("Flaky delivery question?")
     check(wait_for(lambda: len(push.hits("/flaky")) >= flaky_before + 2,
                    timeout=20, what="flaky endpoint retried") is not None,
-          "a failed send retries on the next dispatcher tick and lands")
+          "a 503 send retries inline (Spry: 2 attempts) and lands")
+    flaky_hits = push.hits("/flaky")
+    if len(flaky_hits) >= flaky_before + 2:
+        first, second = flaky_hits[-2], flaky_hits[-1]
+        gap = second["at"] - first["at"]
+        check(0.05 <= gap <= 5.0,
+              "the retry follows the first attempt closely (Spry: 250ms) (%.2fs)"
+              % gap)
+        _, flaky_payload = decrypt_webpush(second, browser, browser_public,
+                                           browser_auth)
+        check(flaky_payload["body"] == "Flaky delivery question?",
+              "the retried delivery is a fresh, complete encryption (%s)"
+              % flaky_payload)
+    else:
+        check(False, "the inline retry fixture did not land")
+
+    print("== a hard-failing endpoint is RETRYABLE, never swept ==")
+    # RFC 8030 §7 semantics (Spry): a persistent 503 exhausts the inline
+    # attempts and classifies RETRYABLE — the notification is NOT
+    # delivered, but the subscription row SURVIVES (only 401/403/404/410
+    # sweep). No give-up log line and no queue table anymore.
+    endpoint_dead = push.url("/dead")
+    push.status_for["/dead"] = 503
+    act(auth, dash["push_subscribe"],
+        {"endpoint": endpoint_dead, **subscribe_fields})
+    dead_before = len(push.hits("/dead"))
+    raise_global_block("Dead endpoint question?")
+    check(wait_for(lambda: len(push.hits("/dead")) >= dead_before + 2,
+                   timeout=20, what="dead endpoint attempts") is not None,
+          "a 503 endpoint is POSTed twice (both inline attempts)")
+    check(len(push.hits("/dead")) <= dead_before + 2,
+          "exactly two attempts — Spry's ATTEMPTS bound, no dispatcher-tick retries")
+    check(subscription_count(endpoint_dead) == 1,
+          "a retryable endpoint is NOT swept (the row survives for the next send)")
 
     print("== plan 12: goal notifications — blocks/success/failure always ==")
     # Goal blocks, completion approval and failure push to EVERY
     # subscribed user (the mirror on the goal-run task stays quiet).
-    push_goal = None
     act(auth, slot_public(auth, "/goals", "goals")["create"], {
         "project_id": "1", "title": "Push goal", "brief": "Notifies everyone.",
         "branch_name": "goal/push"})
@@ -378,7 +425,7 @@ def run(ctx):
           "the goal success payload carries the goal title + click URL (%s)"
           % done_goal_payload)
 
-    print("== task E: unsubscribe removes the subscription ==")
+    print("== unsubscribe removes the subscription ==")
     dash = slot_public(auth, "/", "dashboard")
     check(dash.get("push_subscribed") is True,
           "the bell shows subscribed while endpoints exist")
@@ -401,9 +448,10 @@ def run(ctx):
         return hashlib.sha256(endpoint.encode()).hexdigest()[:16]
 
     print("== per-browser bell: fingerprint state shape ==")
-    # The retry fixture left admin subscribed to /flaky; drop it so
-    # this section owns exactly the subscriptions it asserts on.
-    act(auth, dash["push_unsubscribe"], {"endpoint": endpoint_flaky})
+    # This section owns exactly the subscriptions it asserts on: drop
+    # the dispatch fixtures left on admin's account first.
+    for fixture_endpoint in (endpoint_flaky, endpoint_dead):
+        act(auth, dash["push_unsubscribe"], {"endpoint": fixture_endpoint})
     # Fresh admin subscriptions on TWO fake endpoints = two browsers.
     push2 = FakePushService()
     push2.serve()
@@ -474,50 +522,56 @@ def run(ctx):
     set_types = [d.get("type") for d in result if d.get("type") == "set"]
     check(len(set_types) >= 1, "the action also refreshes the dashboard slot")
 
-    print("== test notification: failure surfaces the real reason ==")
+    print("== test notification: a dead endpoint prunes + reports ==")
+    # Spry sweeps 401/403 exactly like 404/410 (RFC 8030: invalid
+    # credentials for THAT subscription can never heal) — the probe's
+    # DeliveryOutcome[] maps that to `pruned` (NOT failed: nothing
+    # retryable happened), the row goes away and the toast still
+    # separates the two outcomes.
     push2.status_for["/b"] = 401
     b_before = len(push2.hits("/b"))
     result = act(auth, dash["push_test"], {})
     message = action_notify(result) or ""
-    check("reached 1 browser" in message and "HTTP 401" in message,
-          "a refused endpoint reports reached/failed + the status (%s)" % message)
     check(len(push2.hits("/b")) > b_before, "the refused endpoint was still POSTed")
+    check("1 browser" in message and "0 failed" in message,
+          "a refused endpoint reports the delivery + the sweep as not-failed (%s)"
+          % message)
+    check(subscription_count(endpoint_b) == 0,
+          "a 401 endpoint's row is swept (Spry: UNAUTHORIZED sweeps like GONE)")
+    check(wait_for(lambda: len(fingerprints_of(auth)) == 1, timeout=15,
+                   what="bell state drops the swept endpoint") is not None,
+          "the refreshed bell state drops the swept endpoint")
 
-    print("== truthful delivery: 4xx is refused, retried, never delivered ==")
-    # A refused (401) endpoint must NOT count as delivered; the queue
-    # retries on later ticks and — after MAX_ATTEMPTS — gives up WITH a
-    # warning instead of silently claiming success. The subscription
-    # row survives (a VAPID key rotation may heal it).
-    row_before = subscription_count(endpoint_b)
-    b_before = len(push2.hits("/b"))
-    raise_global_block("Refused delivery question?")
-    check(wait_for(lambda: len(push2.hits("/b")) > b_before, timeout=15,
-                   what="first attempt at the refused endpoint") is not None,
-          "the event still reaches the refused endpoint")
-    def give_up_in_log():
-        try:
-            with open(os.path.join(WORK, "app.log")) as handle:
-                return "gave up after %d attempts" % 5 in handle.read()
-        except IOError:
-            return False
-    check(wait_for(give_up_in_log, timeout=30, what="give-up warning in app.log")
-          is not None,
-          "after MAX_ATTEMPTS the give-up warning lands in the log (never silent)")
-    check(subscription_count(endpoint_b) == row_before,
-          "a refused endpoint is NOT pruned (it may recover)")
-    check(len(fingerprints_of(auth)) == 2,
-          "the bell state still lists both endpoints after give-up")
-
-    print("== truthful delivery: 404/410 pruning regression ==")
-    push2.status_for["/b"] = 410
-    b_before = len(push2.hits("/b"))
+    print("== truthful delivery: retryable failures never count as delivered ==")
+    # A persistently 503ing endpoint stays RETRYABLE in Spry's model:
+    # both inline attempts fail, the row SURVIVES and the send_test
+    # probe surfaces the honest `failed` count in its toast.
+    endpoint_bad = push2.url("/bad")
+    push2.status_for["/bad"] = 503
+    act(auth, dash["push_subscribe"],
+        {"endpoint": endpoint_bad, "p256dh": b64url(browser_b_public),
+         "auth": b64url(browser_b_auth)})
+    bad_before = len(push2.hits("/bad"))
+    result = act(auth, dash["push_test"], {})
+    message = action_notify(result) or ""
+    check(len(push2.hits("/bad")) == bad_before + 2,
+          "the retryable endpoint got exactly two inline attempts (%d)"
+          % (len(push2.hits("/bad")) - bad_before))
+    check("1 browser" in message and "failed" in message,
+          "the probe reports the delivery that landed + the failure (%s)" % message)
+    check(subscription_count(endpoint_bad) == 1,
+          "a retryable endpoint is NOT swept — it may recover")
+
+    print("== truthful delivery: 410 pruning regression ==")
+    push2.status_for["/bad"] = 410
+    bad_before = len(push2.hits("/bad"))
     raise_global_block("Prune after refusal question?")
-    check(wait_for(lambda: len(push2.hits("/b")) > b_before, timeout=15,
+    check(wait_for(lambda: len(push2.hits("/bad")) > bad_before, timeout=15,
                    what="POST to the now-gone endpoint") is not None,
           "the event reaches the endpoint before its 410")
-    check(wait_for(lambda: subscription_count(endpoint_b) == 0, timeout=15,
-                   what="410 endpoint pruned after refusal episode") is not None,
-          "a 410 endpoint is pruned even after an earlier refusal episode")
+    check(wait_for(lambda: subscription_count(endpoint_bad) == 0, timeout=15,
+                   what="410 endpoint swept after retryable episode") is not None,
+          "a 410 endpoint is swept even after an earlier retryable episode")
     check(wait_for(lambda: len(fingerprints_of(auth)) == 1, timeout=15,
                    what="bell state drops the pruned endpoint") is not None,
           "the refreshed bell state drops the pruned endpoint")

+ 47 - 19
test/harness.py

@@ -15,8 +15,10 @@ Unlike integration_test.py (which needs a real saddle daemon and a mock
 LLM), this suite spawns everything it needs:
 
 - the clanker-saddle app binary (path from argv[1], or builddir default)
-  in a scratch directory with its own web-config.json (statum keys copied
-  from the repo, plus `saddle_callback_base_url`) and SQLite database
+  in a scratch directory with its own web-config.json (the statum keys
+  and the spry VAPID pair copied from the repo-root config — generated
+  by statum-genkeys + `spry keys` when absent — plus
+  `saddle_callback_base_url`) and SQLite database
 - in-process fake saddle daemons speaking the wire contract: POST
   /register (plan 10: receives {saddle_token, web_base_url}, answers
   {ok, ssh_public_key, version}), POST /wake (records the wake; the
@@ -98,14 +100,18 @@ Plan 11 task D+E (UI + notifications), the `push` shard:
   behind an expander, answered blocks behind an expander; queue screen:
   New Task expander above the filters; dashboard waiting-on-drafts
   block with links to where Enqueue lives (pages shard)
-- real Web Push over VAPID: keypair generated at startup into
-  web-config.json, GET /sw.js service worker with click-through,
-  subscribe/unsubscribe + per-task Notify me actions, block events and
-  waiting-on-drafts pushed globally, first start/failure/completion to
-  opted-in users, best-effort queue with dispatcher-tick retry, 410/404
-  endpoint pruning — payloads verified protocol-level (RFC 8291
-  decryption + ES256 JWT signature) because the harness owns the
-  browser-side keys
+- real Web Push over VAPID (Spry.Push owns the plumbing — the
+  store over spry_push_subscriptions, the RFC 8291/8292 crypto and the
+  delivery POST with inline 2-attempt/250ms retry; the app keeps the
+  payloads, audiences, notify prefs and the bell UX): keys come from
+  web-config.json's "spry" section (written by `spry keys` into the
+  repo-root config the harness copies — no boot-time keygen), GET
+  /sw.js service worker with click-through, subscribe/unsubscribe +
+  per-task Notify me actions, block events and waiting-on-drafts
+  pushed globally, first start/failure/completion to opted-in users,
+  404/410/401/403 endpoint sweeping — payloads verified protocol-level
+  (RFC 8291 decryption + ES256 JWT signature) because the harness owns
+  the browser-side keys
 
 Heredoc completion reports (pages shard):
 
@@ -157,7 +163,7 @@ import urllib.parse
 import urllib.request
 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
 
-# Web Push protocol verification (plan 11 task E): the test harness
+# Web Push protocol verification: the test harness
 # owns the browser-side keys, so it can do everything the push service
 # does — decrypt the RFC 8291 body and verify the ES256 VAPID JWT.
 import base64
@@ -1059,7 +1065,18 @@ class FakeSaddle:
 
 
 def start_app(extra_env=None):
-    config = json.load(open(os.path.join(REPO, "web-config.json")))
+    # Goal 47: Web Push keys live in the "spry" section (no boot-time
+    # keygen). The repo-root web-config.json is generated per saddle
+    # (statum-genkeys) and is gitignored — generate the spry section the
+    # same way `spry keys` does when it is missing, so a fresh saddle
+    # boots with working push keys (it preserves the statum pairs).
+    repo_config = os.path.join(REPO, "web-config.json")
+    config = json.load(open(repo_config))
+    if "spry" not in config or not (config["spry"].get("vapid_private_key")
+                                    or config["spry"].get("vapid_public_key")):
+        subprocess.run(["spry", "keys", "-o", repo_config],
+                       capture_output=True, timeout=60, check=True)
+        config = json.load(open(repo_config))
     config["saddle_callback_base_url"] = WEB
     with open(os.path.join(WORK, "web-config.json"), "w") as handle:
         json.dump(config, handle)
@@ -1161,7 +1178,7 @@ def dump_app_stacks(pid):
 
 
 # =============================================================================
-# Fake push service + Web Push protocol helpers (plan 11 task E)
+# Fake push service + Web Push protocol helpers
 # =============================================================================
 
 def b64url(raw):
@@ -1175,8 +1192,10 @@ def b64url_dec(text):
 class FakePushService:
     """In-process Web Push endpoint server: every subscription the tests
     register points at a path here (a distinct "browser endpoint").
-    Responses are scriptable per path — a fixed status (the 410-prune
-    fixture) or N transient 500s (the retry-next-tick fixture)."""
+    Responses are scriptable per path — a fixed status (the 410-sweep
+    fixture) or N transient 500s (the inline-retry fixture:
+    Spry's sender retries inline 2x/250ms, so N=1 means "lands on the
+    second attempt")."""
 
     def __init__(self):
         self.requests = []            # [{"path", "headers", "body", "at"}]
@@ -1234,7 +1253,8 @@ class FakePushService:
 
 
 def decrypt_webpush(request, client_private_key, client_public, auth_secret):
-    """RFC 8291 decryption of one captured push: returns
+    """RFC 8291 decryption of one captured push (Spry/Locksmith's
+    schedule). Returns
     (record_size, payload dict) — the payload builder's exact output."""
     body = request["body"]
     salt = body[:16]
@@ -1245,12 +1265,20 @@ def decrypt_webpush(request, client_private_key, client_public, auth_secret):
     eph_point = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(),
                                                              eph_public)
     shared = client_private_key.exchange(ec.ECDH(), eph_point)
-    info = b"WebPush: info\x00" + eph_public + client_public
+    # RFC 8291 §3.3: the IKM is HKDF-Extract(auth) then one expand step
+    # binding both public points; §2-3 CEK/NONCE then run RFC 8188 §2.3
+    # over the envelope salt (Spry's fixed rs=4096, always aes128gcm).
+    ikm = HKDF(algorithm=hashes.SHA256(), length=32, salt=auth_secret,
+               info=b"WebPush: info\x00" + client_public + eph_public
+               ).derive(shared)
     cek = HKDF(algorithm=hashes.SHA256(), length=16, salt=salt,
-               info=info).derive(shared + auth_secret)
+               info=b"Content-Encoding: aes128gcm\x00").derive(ikm)
     nonce = HKDF(algorithm=hashes.SHA256(), length=12, salt=salt,
-                 info=info).derive(shared + auth_secret)
+                 info=b"Content-Encoding: nonce\x00").derive(ikm)
     plaintext = AESGCM(cek).decrypt(nonce, ciphertext, None)
+    if plaintext[-1] != 0x02:
+        raise AssertionError("the RFC 8291 delimiter is missing (got 0x%02x)"
+                             % plaintext[-1])
     return record_size, json.loads(plaintext[:-1])  # strip the 0x02 delimiter
 
 

+ 1 - 1
vapi/invercargill-sql-inversion.vapi

@@ -1,6 +1,6 @@
 /* invercargill-sql-inversion.vapi generated by valac 0.56.19, do not modify. */
 
-
+[CCode (gir_namespace = "invercargill_sql_inversion", gir_version = "0.1")]
 namespace InvercargillSqlInversion {
 	[CCode (cheader_filename = "invercargill-sql-inversion.h")]
 	public class DatabaseConfigurator : GLib.Object {

+ 2 - 1
vapi/spry-0.2.deps

@@ -1,2 +1,3 @@
 statum-0.1
-invercargill-sql
+locksmith-1
+libsoup-3.0

+ 426 - 1
vapi/spry-0.2.vapi

@@ -1,6 +1,6 @@
 /* spry-0.2.vapi generated by valac 0.56.19, do not modify. */
 
-
+[CCode (gir_namespace = "spry", gir_version = "0.2")]
 namespace Spry {
 	namespace Actions {
 		[CCode (cheader_filename = "spry-0.2.h")]
@@ -14,6 +14,8 @@ namespace Spry {
 			protected virtual string invalid_date_of_birth_message { get; }
 			protected virtual string invalid_email_message { get; }
 			protected virtual string name_required_message { get; }
+			protected virtual string password_too_common_message { get; }
+			protected virtual string password_too_long_message { get; }
 			protected virtual string password_too_short_message { get; }
 			protected virtual string self_alter_message { get; }
 			protected virtual string update_failed_message { get; }
@@ -31,9 +33,24 @@ namespace Spry {
 			protected virtual string login_uri { get; }
 			protected virtual string password_changed_message { get; }
 			protected virtual string password_mismatch_message { get; }
+			protected virtual string password_too_common_message { get; }
+			protected virtual string password_too_long_message { get; }
 			protected virtual string password_too_short_message { get; }
 		}
 		[CCode (cheader_filename = "spry-0.2.h")]
+		public class CreateProviderAction : Spry.Actions.ProviderManagementAction {
+			public CreateProviderAction ();
+			public override async Statum.DirectiveBuilder handle () throws GLib.Error;
+			protected virtual string created_message { get; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class DeleteProviderAction : Spry.Actions.ProviderManagementAction {
+			public DeleteProviderAction ();
+			public override async Statum.DirectiveBuilder handle () throws GLib.Error;
+			protected virtual string deleted_message { get; }
+			protected virtual string id_required_message { get; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
 		public class DeleteUserAction : Spry.Actions.UserManagementAction {
 			public DeleteUserAction ();
 			public override async Statum.DirectiveBuilder handle () throws GLib.Error;
@@ -73,6 +90,20 @@ namespace Spry {
 				public string surname { get; private set; }
 				public string username { get; private set; }
 			}
+			public class PasswordPolicy : GLib.Object {
+				public enum Rejection {
+					TOO_SHORT,
+					TOO_LONG,
+					TOO_COMMON
+				}
+				public const int ADMIN_MIN_LENGTH;
+				public const string[] DENY_LIST;
+				public const int MAX_LENGTH;
+				public const int MIN_LENGTH;
+				public PasswordPolicy ();
+				public static Spry.Actions.ProfileValidation.PasswordPolicy.Rejection? assess (string password);
+				public static bool is_denied (string password);
+			}
 			public enum Rejection {
 				USERNAME_TOO_SHORT,
 				INVALID_EMAIL,
@@ -82,9 +113,47 @@ namespace Spry {
 			}
 			public ProfileValidation ();
 			public static string? duplicate_message (GLib.Error e, string username_message, string email_message, string other_message);
+			public static string internal_error_message (GLib.Error e, string message);
+			public static string password_rejection_message (Spry.Actions.ProfileValidation.PasswordPolicy.Rejection rejection, string too_short_message, string too_long_message, string too_common_message);
 			public static Spry.Actions.ProfileValidation.Fields? validate (Statum.StatumRequest request, out Spry.Actions.ProfileValidation.Rejection rejection);
 		}
 		[CCode (cheader_filename = "spry-0.2.h")]
+		public abstract class ProviderManagementAction : Statum.StatumAction {
+			protected Spry.Sso.ProviderService providers;
+			protected ProviderManagementAction ();
+			protected string form_auth_method ();
+			protected string form_client_id ();
+			protected string form_client_secret ();
+			protected string form_display_name ();
+			protected bool form_enabled ();
+			protected string form_issuer ();
+			protected string form_scope ();
+			protected string form_slug ();
+			protected Statum.DirectiveBuilder invalid (GLib.Error e);
+			protected async Statum.DirectiveBuilder? require_permission_guard ();
+			protected int64 target_provider_id ();
+			protected virtual string required_permission { get; }
+			protected virtual string unauthorised_uri { get; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class PushSubscribeAction : Statum.StatumAction {
+			public PushSubscribeAction ();
+			public override async Statum.DirectiveBuilder handle () throws GLib.Error;
+			protected virtual string invalid_subscription_message { get; }
+			protected virtual string login_uri { get; }
+			protected virtual string subscribe_failed_message { get; }
+			protected virtual string subscribed_message { get; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class PushUnsubscribeAction : Statum.StatumAction {
+			public PushUnsubscribeAction ();
+			public override async Statum.DirectiveBuilder handle () throws GLib.Error;
+			protected virtual string endpoint_required_message { get; }
+			protected virtual string login_uri { get; }
+			protected virtual string unsubscribe_failed_message { get; }
+			protected virtual string unsubscribed_message { get; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
 		public class RegisterAction : Statum.StatumAction {
 			public RegisterAction ();
 			public override async Statum.DirectiveBuilder handle () throws GLib.Error;
@@ -98,6 +167,8 @@ namespace Spry {
 			protected virtual string login_uri { get; }
 			protected virtual string name_required_message { get; }
 			protected virtual string password_mismatch_message { get; }
+			protected virtual string password_too_common_message { get; }
+			protected virtual string password_too_long_message { get; }
 			protected virtual string password_too_short_message { get; }
 			protected virtual string registration_failed_message { get; }
 			protected virtual string username_too_short_message { get; }
@@ -112,6 +183,13 @@ namespace Spry {
 			protected virtual string user_required_message { get; }
 		}
 		[CCode (cheader_filename = "spry-0.2.h")]
+		public class SetProviderEnabledAction : Spry.Actions.ProviderManagementAction {
+			public SetProviderEnabledAction ();
+			public override async Statum.DirectiveBuilder handle () throws GLib.Error;
+			protected virtual string id_required_message { get; }
+			protected virtual string toggled_message { get; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
 		public class SetUserEnabledAction : Spry.Actions.UserManagementAction {
 			public SetUserEnabledAction ();
 			public override async Statum.DirectiveBuilder handle () throws GLib.Error;
@@ -121,6 +199,28 @@ namespace Spry {
 			protected virtual string user_required_message { get; }
 		}
 		[CCode (cheader_filename = "spry-0.2.h")]
+		public abstract class SsoAction : Statum.StatumAction {
+			protected Spry.Sso.SsoService sso;
+			protected SsoAction ();
+			protected Statum.DirectiveBuilder login_failed (GLib.Error e, string slug);
+			protected string redirect_uri_for (string slug);
+			protected string request_origin ();
+			protected virtual string login_failed_message { get; }
+			protected Astralis.HttpContext? request_http { get; private set; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class SsoLoginAction : Spry.Actions.SsoAction {
+			public SsoLoginAction ();
+			public override async Statum.DirectiveBuilder handle () throws GLib.Error;
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class UpdateProviderAction : Spry.Actions.ProviderManagementAction {
+			public UpdateProviderAction ();
+			public override async Statum.DirectiveBuilder handle () throws GLib.Error;
+			protected virtual string id_required_message { get; }
+			protected virtual string updated_message { get; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
 		public class UserActionPrivate : GLib.Object {
 			public UserActionPrivate ();
 			public int64 user_id { get; set; }
@@ -148,19 +248,31 @@ namespace Spry {
 				public override string name { get; }
 				public override uint64 serial { get; }
 			}
+			[CCode (cheader_filename = "spry-0.2.h")]
+			public class M0002_SessionsValidAfter : InvercargillSql.Migrations.Migration {
+				public M0002_SessionsValidAfter ();
+				public override void down (InvercargillSql.Migrations.MigrationBuilder b);
+				public override void up (InvercargillSql.Migrations.MigrationBuilder b);
+				public override string migration_namespace { get; }
+				public override string name { get; }
+				public override uint64 serial { get; }
+			}
 		}
 		[CCode (cheader_filename = "spry-0.2.h")]
 		public class UserEntity : GLib.Object {
 			public UserEntity ();
 			public static void entity_mapping (InvercargillSql.Orm.EntityMapperBuilder<Spry.Authentication.UserEntity> cfg);
+			public static GLib.DateTime EPOCH { get; }
 			public GLib.DateTime created { get; set; }
 			public GLib.DateTime date_of_birth { get; set; }
+			public GLib.DateTime effective_sessions_valid_after { get; }
 			public string email { get; set; }
 			public bool enabled { get; set; }
 			public string forename { get; set; }
 			public int64 id { get; set; }
 			public GLib.DateTime modified { get; set; }
 			public string password_hash { get; set; }
+			public GLib.DateTime? sessions_valid_after { get; set; }
 			public string surname { get; set; }
 			public string username { get; set; }
 		}
@@ -178,6 +290,7 @@ namespace Spry {
 			public static void projection_mapping (InvercargillSql.Orm.Projections.ProjectionBuilder<Spry.Authentication.UserProjection> cfg) throws GLib.Error;
 			public GLib.DateTime created { get; set; }
 			public GLib.DateTime date_of_birth { get; set; }
+			public GLib.DateTime effective_sessions_valid_after { get; }
 			public string email { get; set; }
 			public bool enabled { get; set; }
 			public string forename { get; set; }
@@ -185,22 +298,35 @@ namespace Spry {
 			public GLib.DateTime modified { get; set; }
 			public string password_hash { get; set; }
 			public Invercargill.ImmutableLot<string> permissions { owned get; }
+			public GLib.DateTime? sessions_valid_after { get; set; }
 			public string surname { get; set; }
 			public string username { get; }
 		}
 		[CCode (cheader_filename = "spry-0.2.h")]
 		public class UserService : GLib.Object {
+			public struct HashParameters {
+				public string algorithm;
+				public int version;
+				public int64 memory;
+				public int64 passes;
+				public int64 parallelism;
+			}
 			public UserService ();
 			public async Spry.Authentication.UserEntity alter_user (int64 user_id, string username, string email, string forename, string surname, GLib.DateTime date_of_birth, bool enabled) throws GLib.Error;
 			public async Spry.Authentication.UserProjection? authenticate_user (string username, string password) throws GLib.Error;
 			public async void clear_user_permissions (int64 user_id) throws GLib.Error;
 			public async void delete_user (int64 user_id) throws GLib.Error;
+			public static bool frame_is_valid_at (Spry.Authentication.UserProjection user, GLib.DateTime as_at);
 			public async Spry.Authentication.UserProjection? get_user (int64 user_id) throws GLib.Error;
 			public async Spry.Authentication.UserProjection? get_user_by_username (string username) throws GLib.Error;
 			public async Invercargill.ImmutableLot<string> get_user_permissions (int64 user_id) throws GLib.Error;
+			public static bool hash_needs_rehash (string hash_text);
 			public async Invercargill.ImmutableLot<Spry.Authentication.UserProjection> list_users (int64 offset = 0, int64 limit = 100) throws GLib.Error;
+			public static Spry.Authentication.UserService.HashParameters? parse_hash_parameters (string hash_text);
 			public async Spry.Authentication.UserEntity register_user (string username, string email, string forename, string surname, GLib.DateTime date_of_birth, string password, bool enabled = true) throws GLib.Error;
+			public async void rehash_password (int64 user_id, string password) throws GLib.Error;
 			public async void remove_user_permission (int64 user_id, string permission) throws GLib.Error;
+			public static GLib.DateTime session_epoch_now ();
 			public async void set_password (int64 user_id, string password) throws GLib.Error;
 			public async void set_user_enabled (int64 user_id, bool enabled) throws GLib.Error;
 			public async void set_user_permission (int64 user_id, string permission) throws GLib.Error;
@@ -210,6 +336,293 @@ namespace Spry {
 			USER_NOT_FOUND
 		}
 	}
+	namespace Push {
+		namespace Migrations {
+			[CCode (cheader_filename = "spry-0.2.h")]
+			public class M0003_PushSubscriptions : InvercargillSql.Migrations.Migration {
+				public M0003_PushSubscriptions ();
+				public override void down (InvercargillSql.Migrations.MigrationBuilder b);
+				public override void up (InvercargillSql.Migrations.MigrationBuilder b);
+				public override string migration_namespace { get; }
+				public override string name { get; }
+				public override uint64 serial { get; }
+			}
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class PushSender : GLib.Object {
+			public Astralis.WebConfig? config;
+			public const int ATTEMPTS;
+			public const string DEFAULT_SUBJECT;
+			public const int DEFAULT_TTL_SECONDS;
+			public const uint RETRY_DELAY_MS;
+			public PushSender ();
+			public bool is_configured ();
+			public void reload ();
+			public async Spry.Push.DeliveryOutcome send_to_subscription (Spry.Push.PushService pushes, Spry.Push.PushSubscriptionEntity row, string payload, int ttl_seconds) throws GLib.Error;
+			public async Spry.Push.DeliveryOutcome[] send_to_user (Spry.Push.PushService pushes, int64 user_id, string payload, int ttl_seconds = DEFAULT_TTL_SECONDS) throws GLib.Error;
+			public string vapid_public_key ();
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class PushService : GLib.Object {
+			public PushService ();
+			public async void forget (int64 subscription_id) throws GLib.Error;
+			public async Spry.Push.PushSubscriptionEntity? get_by_endpoint (string endpoint) throws GLib.Error;
+			public async Spry.Push.PushSubscriptionEntity subscribe (int64 user_id, string endpoint, string p256dh_b64, string auth_b64) throws GLib.Error;
+			public async Invercargill.ImmutableLot<Spry.Push.PushSubscriptionEntity> subscriptions_for (int64 user_id) throws GLib.Error;
+			public async bool unsubscribe (int64 user_id, string endpoint) throws GLib.Error;
+			public async int unsubscribe_for_user (int64 user_id) throws GLib.Error;
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class PushSubscriptionEntity : GLib.Object {
+			public PushSubscriptionEntity ();
+			public static void entity_mapping (InvercargillSql.Orm.EntityMapperBuilder<Spry.Push.PushSubscriptionEntity> cfg);
+			public Locksmith.WebPush.Subscription to_subscription () throws Locksmith.Error;
+			public string auth { get; set; }
+			public GLib.DateTime created { get; set; }
+			public string endpoint { get; set; }
+			public int64 id { get; set; }
+			public string p256dh { get; set; }
+			public int64 user_id { get; set; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public enum DeliveryOutcome {
+			DELIVERED,
+			UNAUTHORIZED,
+			GONE,
+			RETRYABLE,
+			SKIPPED
+		}
+	}
+	namespace Resources {
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class SpryCss : Statum.ConstantStatumResource {
+			public SpryCss ();
+			public override string get_best_encoding (Invercargill.Set<string> supported);
+			public override unowned uint8[] get_encoding (string encoding);
+			public override string content_type { get; }
+			public override string file_hash { get; }
+			public override string name { get; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class SpryPushJs : Statum.ConstantStatumResource {
+			public SpryPushJs ();
+			public override string get_best_encoding (Invercargill.Set<string> supported);
+			public override unowned uint8[] get_encoding (string encoding);
+			public override string content_type { get; }
+			public override string file_hash { get; }
+			public override string name { get; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class SpryThemeAiry : Statum.ConstantStatumResource {
+			public SpryThemeAiry ();
+			public override string get_best_encoding (Invercargill.Set<string> supported);
+			public override unowned uint8[] get_encoding (string encoding);
+			public override string content_type { get; }
+			public override string file_hash { get; }
+			public override string name { get; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class SpryThemeDwarf : Statum.ConstantStatumResource {
+			public SpryThemeDwarf ();
+			public override string get_best_encoding (Invercargill.Set<string> supported);
+			public override unowned uint8[] get_encoding (string encoding);
+			public override string content_type { get; }
+			public override string file_hash { get; }
+			public override string name { get; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class SpryThemeFormal : Statum.ConstantStatumResource {
+			public SpryThemeFormal ();
+			public override string get_best_encoding (Invercargill.Set<string> supported);
+			public override unowned uint8[] get_encoding (string encoding);
+			public override string content_type { get; }
+			public override string file_hash { get; }
+			public override string name { get; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class SpryThemeGlassy : Statum.ConstantStatumResource {
+			public SpryThemeGlassy ();
+			public override string get_best_encoding (Invercargill.Set<string> supported);
+			public override unowned uint8[] get_encoding (string encoding);
+			public override string content_type { get; }
+			public override string file_hash { get; }
+			public override string name { get; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class SpryThemeH2o : Statum.ConstantStatumResource {
+			public SpryThemeH2o ();
+			public override string get_best_encoding (Invercargill.Set<string> supported);
+			public override unowned uint8[] get_encoding (string encoding);
+			public override string content_type { get; }
+			public override string file_hash { get; }
+			public override string name { get; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class SpryThemeLivingSky : Statum.ConstantStatumResource {
+			public SpryThemeLivingSky ();
+			public override string get_best_encoding (Invercargill.Set<string> supported);
+			public override unowned uint8[] get_encoding (string encoding);
+			public override string content_type { get; }
+			public override string file_hash { get; }
+			public override string name { get; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class SpryThemeModern : Statum.ConstantStatumResource {
+			public SpryThemeModern ();
+			public override string get_best_encoding (Invercargill.Set<string> supported);
+			public override unowned uint8[] get_encoding (string encoding);
+			public override string content_type { get; }
+			public override string file_hash { get; }
+			public override string name { get; }
+		}
+	}
+	namespace Sso {
+		namespace Migrations {
+			[CCode (cheader_filename = "spry-0.2.h")]
+			public class M0004_OAuthProviders : InvercargillSql.Migrations.Migration {
+				public M0004_OAuthProviders ();
+				public override void down (InvercargillSql.Migrations.MigrationBuilder b);
+				public override void up (InvercargillSql.Migrations.MigrationBuilder b);
+				public override string migration_namespace { get; }
+				public override string name { get; }
+				public override uint64 serial { get; }
+			}
+			[CCode (cheader_filename = "spry-0.2.h")]
+			public class M0005_OAuthIdentities : InvercargillSql.Migrations.Migration {
+				public M0005_OAuthIdentities ();
+				public override void down (InvercargillSql.Migrations.MigrationBuilder b);
+				public override void up (InvercargillSql.Migrations.MigrationBuilder b);
+				public override string migration_namespace { get; }
+				public override string name { get; }
+				public override uint64 serial { get; }
+			}
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class LoginAttempt : GLib.Object {
+			public LoginAttempt ();
+			public string authorize_uri { get; set; }
+			public Spry.Sso.SsoService.SsoPrivate private_data { get; set; }
+			public string provider_slug { get; set; }
+			public Spry.Sso.SsoService.SsoPublic public_data { get; set; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class LoginMode : GLib.Object {
+			public enum Mode {
+				PLAIN,
+				HYBRID,
+				SSO_ONLY
+			}
+			public LoginMode ();
+			public static Spry.Sso.LoginMode resolve (Astralis.WebConfig? config);
+			public bool is_hybrid { get; }
+			public bool is_plain { get; }
+			public bool is_sso_only { get; }
+			public Spry.Sso.LoginMode.Mode mode { get; private set; }
+			public string provider { get; private set; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class OAuthIdentityEntity : GLib.Object {
+			public OAuthIdentityEntity ();
+			public static void entity_mapping (InvercargillSql.Orm.EntityMapperBuilder<Spry.Sso.OAuthIdentityEntity> cfg);
+			public GLib.DateTime created { get; set; }
+			public int64 id { get; set; }
+			public string provider_slug { get; set; }
+			public string subject { get; set; }
+			public int64 user_id { get; set; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class OAuthProviderEntity : GLib.Object {
+			public const string DEFAULT_AUTH_METHOD;
+			public const string DEFAULT_SCOPE;
+			public OAuthProviderEntity ();
+			public Locksmith.OAuth2.ClientAuthentication.Method authentication_method () throws Locksmith.Error;
+			public static void entity_mapping (InvercargillSql.Orm.EntityMapperBuilder<Spry.Sso.OAuthProviderEntity> cfg);
+			public string auth_method { get; set; }
+			public string client_id { get; set; }
+			public string client_secret { get; set; }
+			public GLib.DateTime created { get; set; }
+			public string display_name { get; set; }
+			public string effective_scope { get; }
+			public bool enabled { get; set; }
+			public int64 id { get; set; }
+			public string issuer { get; set; }
+			public GLib.DateTime modified { get; set; }
+			public string scope { get; set; }
+			public string slug { get; set; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class ProviderService : GLib.Object {
+			public const string SLUG_ALPHABET;
+			public ProviderService ();
+			public async Spry.Sso.OAuthProviderEntity create_provider (string slug, string display_name, string issuer, string client_id, string client_secret, string scope = "", string auth_method = "", bool enabled = true) throws GLib.Error;
+			public async int delete_identities_for_user (int64 user_id) throws GLib.Error;
+			public async void delete_provider (int64 provider_id, bool delete_identities = false) throws GLib.Error;
+			public async Spry.Sso.OAuthProviderEntity? get_by_id (int64 provider_id) throws GLib.Error;
+			public async Spry.Sso.OAuthProviderEntity? get_by_slug (string slug) throws GLib.Error;
+			public async Invercargill.ImmutableLot<Spry.Sso.OAuthProviderEntity> list_enabled_providers () throws GLib.Error;
+			public async Invercargill.ImmutableLot<Spry.Sso.OAuthProviderEntity> list_providers () throws GLib.Error;
+			public async Spry.Sso.OAuthProviderEntity set_enabled (int64 provider_id, bool enabled) throws GLib.Error;
+			public async Spry.Sso.OAuthProviderEntity update_provider (int64 provider_id, string slug, string display_name, string issuer, string client_id, string client_secret, string scope = "", string auth_method = "", bool enabled = true) throws GLib.Error;
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class SsoHttpClient : GLib.Object {
+			public const uint CACHE_TTL_SECONDS;
+			public const string OAUTH_WELL_KNOWN;
+			public const string OIDC_WELL_KNOWN;
+			public const uint TIMEOUT_SECONDS;
+			public SsoHttpClient ();
+			public async Locksmith.OAuth2.IssuerMetadata discover (string slug, string issuer) throws GLib.Error;
+			public async string get_json (string userinfo_endpoint, string access_token) throws GLib.Error;
+			public async string post_form (string token_endpoint, Locksmith.OAuth2.TokenRequest.Parts parts) throws GLib.Error;
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class SsoService : GLib.Object {
+			public class SsoPrivate : GLib.Object {
+				public SsoPrivate ();
+				public string provider { get; set; }
+				public string state { get; set; }
+				public string verifier { get; set; }
+			}
+			public class SsoPublic : GLib.Object {
+				public SsoPublic ();
+				public string provider { get; set; }
+			}
+			public const string SSO_SLOT_TYPE;
+			public SsoService ();
+			public async Spry.Sso.LoginAttempt begin_login (string slug, string redirect_uri) throws GLib.Error;
+			public async Spry.Authentication.UserProjection complete_login (Spry.Sso.SsoService.SsoPrivate held, string code, string state, string redirect_uri) throws GLib.Error;
+			public async Locksmith.OAuth2.IssuerMetadata discover (Spry.Sso.OAuthProviderEntity provider) throws GLib.Error;
+			public async Spry.Sso.UserInfo fetch_userinfo (Spry.Sso.OAuthProviderEntity provider, Locksmith.OAuth2.IssuerMetadata metadata, string access_token) throws GLib.Error;
+			public async Spry.Sso.OAuthProviderEntity require_provider (string slug) throws GLib.Error;
+			public async Spry.Authentication.UserProjection resolve_identity (string provider_slug, Spry.Sso.UserInfo userinfo) throws GLib.Error;
+			public async string unique_username (string candidate) throws GLib.Error;
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class UserInfo : GLib.Object {
+			public UserInfo ();
+			public string email { get; set; }
+			public string family_name { get; set; }
+			public string given_name { get; set; }
+			public string sub { get; set; }
+			public string username { get; set; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public errordomain ProviderError {
+			NOT_FOUND,
+			DUPLICATE_SLUG,
+			INVALID
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public errordomain SsoError {
+			UNKNOWN_PROVIDER,
+			PROVIDER_DISABLED,
+			DISCOVERY_FAILED,
+			TOKEN_EXCHANGE_FAILED,
+			USERINFO_FAILED,
+			ACCOUNT_UNAVAILABLE,
+			CRYPTO
+		}
+	}
 	[CCode (cheader_filename = "spry-0.2.h")]
 	public class AuthPrivate : GLib.Object {
 		public AuthPrivate ();
@@ -242,8 +655,10 @@ namespace Spry {
 		public static Spry.CurrentUser current (Statum.StatumRequest request);
 		public static async void ensure_admin (Spry.Authentication.UserService users, string username, string email, string password) throws GLib.Error;
 		public static Statum.DirectiveBuilder login (Statum.DirectiveBuilder directives, Spry.Authentication.UserProjection user, Statum.ActionRegistry action_registry) throws GLib.Error;
+		public static Spry.Sso.LoginMode login_mode (Astralis.WebConfig? config);
 		public static bool permission_matches (string[] patterns, string permission);
 		public static void register_actions (Statum.StatumConfigurator cfg);
+		public static Statum.DirectiveBuilder relogin (Statum.DirectiveBuilder directives, Spry.Authentication.UserProjection user, Statum.ActionRegistry action_registry) throws GLib.Error;
 		public static async Statum.DirectiveBuilder? require_login (Statum.DirectiveBuilder directives, Statum.StatumRequest request, Spry.Authentication.UserService users, string redirect = "/login");
 		public static async Statum.DirectiveBuilder? require_permission (Statum.DirectiveBuilder directives, Statum.StatumRequest request, Spry.Authentication.UserService users, string permission, string redirect = "/login");
 	}
@@ -251,4 +666,14 @@ namespace Spry {
 	public class SpryModule : GLib.Object, Inversion.Module {
 		public SpryModule ();
 	}
+	[CCode (cheader_filename = "spry-0.2.h")]
+	public class SsoCallbackEntrypoint : Statum.StatumEntrypoint {
+		public const string SSO_SLOT_TYPE;
+		public SsoCallbackEntrypoint ();
+		public override async Statum.DirectiveBuilder handle () throws GLib.Error;
+		protected string request_origin ();
+		public virtual string failure_uri { get; }
+		public virtual string landing_uri { get; }
+		public virtual string login_failed_message { get; }
+	}
 }

+ 42 - 4
vapi/statum-0.1.vapi

@@ -11,7 +11,7 @@ namespace Statum {
 			public string author_string (string @namespace, string data);
 			public Invercargill.Properties read_properties (string @namespace, string blob) throws GLib.Error;
 			public string read_string (string @namespace, string blob) throws Statum.Cryptography.EncryptionError;
-			public EncryptionProvider.with_keys (uint8[] signing_secret_key, uint8[] signing_public_key, uint8[] sealing_secret_key, uint8[] sealing_public_key);
+			public EncryptionProvider.with_keys (uint8[] signing_secret_key, uint8[] signing_public_key, uint8[] sealing_secret_key, uint8[] sealing_public_key) throws Locksmith.Error;
 		}
 		[CCode (cheader_filename = "statum-0.1.h")]
 		public class SigningProvider : GLib.Object {
@@ -22,7 +22,7 @@ namespace Statum {
 			public bool verify (string content, string signature_b64);
 			public void verify_frame (Statum.Model.FrameDto frame) throws Statum.Cryptography.SigningError;
 			public bool verify_with_key (string content, string signature_b64, uint8[] verification_public_key);
-			public SigningProvider.with_keys (uint8[] secret_key, uint8[] public_key);
+			public SigningProvider.with_keys (uint8[] secret_key, uint8[] public_key) throws Locksmith.Error;
 			public string signer_id { get; set; }
 		}
 		[CCode (cheader_filename = "statum-0.1.h")]
@@ -217,17 +217,39 @@ namespace Statum {
 		public GLib.Type? resolve (string guid);
 	}
 	[CCode (cheader_filename = "statum-0.1.h")]
+	public class AuthorizationPolicy : GLib.Object {
+		public delegate Astralis.HttpResult? Guard (Astralis.HttpContext? http_context);
+		public AuthorizationPolicy ();
+		public Astralis.HttpResult? authorize_channel (Astralis.HttpContext? http_context);
+		public Astralis.HttpResult? authorize_channel_subscriptions (Astralis.HttpContext? http_context);
+		public Astralis.HttpResult? authorize_entrypoint (Astralis.HttpContext? http_context);
+		public void guard_channel (owned Statum.AuthorizationPolicy.Guard guard);
+		public void guard_channel_subscriptions (owned Statum.AuthorizationPolicy.Guard guard);
+		public void guard_entrypoint (owned Statum.AuthorizationPolicy.Guard guard);
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
 	public class ChannelEndpoint : Astralis.SseEndpoint, Statum.ChannelService {
+		public const int MAX_CONNECTIONS_PER_IP;
+		public const int MAX_SUBSCRIPTIONS_PER_CHANNEL;
+		public const int MAX_TOTAL_CONNECTIONS;
 		public ChannelEndpoint ();
+		public bool can_accept_connection (string? ip);
 		public bool has_channel (string id);
 		public override async void new_connection (Astralis.HttpContext http_context, Astralis.RouteContext route_context, Astralis.SseStream stream);
-		public void subscribe (string id, string key);
+		public bool subscribe (string id, string key);
 		public void unsubscribe (string id, string key);
+		public Statum.ConnectionTracker connection_tracker { get; private set; }
 		public override uint retry_interval { get; }
 	}
 	[CCode (cheader_filename = "statum-0.1.h")]
+	public class ChannelGatewayEndpoint : GLib.Object, Astralis.Endpoint {
+		public ChannelGatewayEndpoint ();
+		public virtual Astralis.HttpResult? authorize (Astralis.HttpContext http_context);
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
 	public class ChannelSubscriptionEndpoint : GLib.Object, Astralis.Endpoint {
 		public ChannelSubscriptionEndpoint ();
+		public virtual Astralis.HttpResult? authorize (Astralis.HttpContext http_context);
 	}
 	[CCode (cheader_filename = "statum-0.1.h")]
 	public class ClientScript : Statum.ConstantStatumResource {
@@ -248,6 +270,17 @@ namespace Statum {
 		public override string name { get; }
 	}
 	[CCode (cheader_filename = "statum-0.1.h")]
+	public class ConnectionTracker : GLib.Object {
+		public ConnectionTracker (int max_total = ChannelEndpoint.MAX_TOTAL_CONNECTIONS, int max_per_ip = ChannelEndpoint.MAX_CONNECTIONS_PER_IP);
+		public void acquired (string? ip);
+		public bool can_accept (string? ip);
+		public int open_for (string? ip);
+		public int open_total ();
+		public void released (string? ip);
+		public int max_per_ip { get; private set; }
+		public int max_total { get; private set; }
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
 	public abstract class ConstantStatumResource : GLib.Object, Statum.StatumResource {
 		protected ConstantStatumResource ();
 		public abstract string get_best_encoding (Invercargill.Set<string> supported);
@@ -283,6 +316,7 @@ namespace Statum {
 	[CCode (cheader_filename = "statum-0.1.h")]
 	public class EntrypointEndpoint : GLib.Object, Astralis.Endpoint {
 		public EntrypointEndpoint ();
+		public virtual Astralis.HttpResult? authorize (Astralis.HttpContext http_context);
 	}
 	[CCode (cheader_filename = "statum-0.1.h")]
 	public class EntrypointMatch : GLib.Object {
@@ -415,6 +449,9 @@ namespace Statum {
 		public void add_resource<T> ();
 		public void add_static_page<TPage> () throws GLib.Error;
 		public void global<TPublic> (string type_name, TPublic initial) throws GLib.Error;
+		public void guard_channel (owned Statum.AuthorizationPolicy.Guard guard);
+		public void guard_channel_subscriptions (owned Statum.AuthorizationPolicy.Guard guard);
+		public void guard_entrypoint (owned Statum.AuthorizationPolicy.Guard guard);
 		public void topic<TTopic> (string prefix);
 		public void topic_modifier<TModifier,TState> (string prefix);
 	}
@@ -528,7 +565,8 @@ namespace Statum {
 	}
 	[CCode (cheader_filename = "statum-0.1.h")]
 	public errordomain StateError {
-		SLOT_NOT_FOUND
+		SLOT_NOT_FOUND,
+		FRAME_EXPIRED
 	}
 	[CCode (cheader_filename = "statum-0.1.h")]
 	public errordomain StatumError {