Browse Source

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

Billy Barrow 10 hours ago
parent
commit
22502bfeaf

+ 3 - 0
.gitignore

@@ -8,3 +8,6 @@ compile_commands.json
 # Python bytecode
 # Python bytecode
 test/__pycache__/
 test/__pycache__/
 __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,
 Browse to `http://localhost:8080`, log in with the seeded admin account,
 register the saddle on `/saddles` with host `localhost:7100`, and create
 register the saddle on `/saddles` with host `localhost:7100`, and create
 your first project and task. `spry dev` wraps the first shell (rebuild +
 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.
 alive across restarts and is never packaged or baked into images.
 
 
 The database is `./clanker-saddle.sqlite`, or `SPRY_DB_PATH` when set.
 The database is `./clanker-saddle.sqlite`, or `SPRY_DB_PATH` when set.
@@ -362,12 +363,24 @@ resume path.
 ## Browser notifications (Web Push)
 ## Browser notifications (Web Push)
 
 
 The app speaks the real Web Push protocol (VAPID + RFC 8291 payload
 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
 To enable notifications in a browser: open the dashboard and press
 **Enable notifications** — the page requests permission, registers the
 **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
 questions, RFCs and review requests notify every subscribed user, and
 goal blocks plus goal success/failure ALWAYS notify every subscribed
 goal blocks plus goal success/failure ALWAYS notify every subscribed
 user. Clicking a notification opens the task's or goal's page.
 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
 ## 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
 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
 ## 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
 server-side invariants run always; the browser audit runs when
 chromium is present (CI without chromium skips it gracefully — pass
 chromium is present (CI without chromium skips it gracefully — pass
 `T12MOBILE_BROWSER=1` to require it).
 `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
 # pkg-config libdir as the INSTALL rpath — inside a usm container the
 # same expression resolves the (harmless, existing) system libdir.
 # same expression resolves the (harmless, existing) system libdir.
 devstack_libdir = spry_dep.get_variable(pkgconfig: 'libdir', default_value: '')
 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
 # Statum build tools — statum-mkpstm compiles pages, statum-mkres embeds
 # resources. Resolved from PATH at setup time.
 # resources. Resolved from PATH at setup time.
@@ -228,6 +224,7 @@ app_sources = files(
     'src/Data/Migrations/M0009_TurnCapPark.vala',
     'src/Data/Migrations/M0009_TurnCapPark.vala',
     'src/Data/Migrations/M0010_ModelConfig.vala',
     'src/Data/Migrations/M0010_ModelConfig.vala',
     'src/Data/Migrations/M0011_UserPreferences.vala',
     'src/Data/Migrations/M0011_UserPreferences.vala',
+    'src/Data/Migrations/M0012_ProviderPushSubscriptions.vala',
     'src/Services/ProjectService.vala',
     'src/Services/ProjectService.vala',
     'src/Services/TaskService.vala',
     'src/Services/TaskService.vala',
     'src/Services/GoalService.vala',
     'src/Services/GoalService.vala',
@@ -294,10 +291,6 @@ app_sources = files(
     # spry:sources-end
     # 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 = [
 app_generated = [
     # spry:generated-begin
     # spry:generated-begin
     home_page,
     home_page,
@@ -323,8 +316,7 @@ app_generated = [
 ]
 ]
 
 
 clanker_saddle_exe = executable('clanker-saddle',
 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')],
     vala_args: ['--vapidir=' + join_paths(meson.current_source_dir(), 'vapi')],
     dependencies: [
     dependencies: [
         spry_dep,
         spry_dep,
@@ -342,7 +334,6 @@ clanker_saddle_exe = executable('clanker-saddle',
         gio_dep,
         gio_dep,
         gobject_dep,
         gobject_dep,
         glib_dep,
         glib_dep,
-        libcrypto_dep,
     ],
     ],
     install: true,
     install: true,
     install_rpath: devstack_libdir
     install_rpath: devstack_libdir

+ 2 - 2
src/Api/PushApi.vala

@@ -6,8 +6,8 @@ using ClankerSaddle.Services;
 namespace ClankerSaddle.Api {
 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
      * page. The push handler shows the notification the app built
      * ({title, body, url}); `notificationclick` focuses an open window
      * ({title, body, url}); `notificationclick` focuses an open window
      * on the task page or opens one. Deliberately unauthenticated: the
      * 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.
      * user; block events notify all push-subscribed users globally.
      */
      */
     public class TaskNotifyPref : Object {
     public class TaskNotifyPref : Object {
@@ -840,8 +813,8 @@ namespace ClankerSaddle.Data {
      * column and must not touch this table.
      * column and must not touch this table.
      *
      *
      * `user_id` references the Spry users table by value (no FK — the
      * `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 {
     public class UserPreference : Object {
 
 

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

@@ -12,8 +12,7 @@
          * need no further migrations.
          * need no further migrations.
          *
          *
          * `user_id` references the Spry users table BY VALUE (no FK —
          * `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
          * IDEMPOTENCE: same contract as M0002–M0010 — up() runs before
          * the apply transaction opens (the builder only queues), so it
          * 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 saddles.mark_task_comments_delivered(task.id);
             yield pulse.bump(task.id, "task dispatched");
             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
             // notifies its opted-in users — fired from the same place
             // the dispatch pulse bumps (single funnel).
             // the dispatch pulse bumps (single funnel).
             if (claim.first_start) {
             if (claim.first_start) {

+ 0 - 5
src/Services/Dispatcher.vala

@@ -29,7 +29,6 @@ namespace ClankerSaddle.Services {
 
 
         private Container container = inject<Container>();
         private Container container = inject<Container>();
         private SaddlePollState poll_state = inject<SaddlePollState>();
         private SaddlePollState poll_state = inject<SaddlePollState>();
-        private PushService push = inject<PushService>();
         private AnswerDeliveryHub answer_hub = inject<AnswerDeliveryHub>();
         private AnswerDeliveryHub answer_hub = inject<AnswerDeliveryHub>();
         private bool ticking = false;
         private bool ticking = false;
         private int64 tick_started_ms = 0;
         private int64 tick_started_ms = 0;
@@ -140,10 +139,6 @@ namespace ClankerSaddle.Services {
                     warning("[ClankerSaddle] Answer delivery pass failed: %s", e.message);
                     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()) {
                 foreach (var saddle in yield saddles.list_busy_saddles()) {
                     // Goal 28 (the ghost-working sweep): a busy record
                     // Goal 28 (the ghost-working sweep): a busy record
                     // whose session has been push-silent past
                     // whose session has been push-silent past

+ 219 - 654
src/Services/PushService.vala

@@ -4,41 +4,18 @@ using Invercargill.Expressions;
 using InvercargillSql;
 using InvercargillSql;
 using InvercargillSql.Orm;
 using InvercargillSql.Orm;
 using Inversion;
 using Inversion;
-using Soup;
-using Astralis;
+using Spry.Push;
 using ClankerSaddle.Data;
 using ClankerSaddle.Data;
 
 
 namespace ClankerSaddle.Services {
 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 class PushNotification : Object {
         public string title { get; set; default = ""; }
         public string title { get; set; default = ""; }
@@ -49,198 +26,73 @@ namespace ClankerSaddle.Services {
     /**
     /**
      * The roll-up of one {@link PushService.send_test} probe: counts
      * The roll-up of one {@link PushService.send_test} probe: counts
      * per outcome plus the first failure's detail for the toast.
      * 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 class PushTestReport : Object {
         public int delivered { get; set; }
         public int delivered { get; set; }
         public int failed { 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 int pruned { get; set; }
         public string first_failure { get; set; default = ""; }
         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 {
     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 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 {
             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) {
             } 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. */
         /** 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) {
         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) {
         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 {
             try {
                 var scope = container.create_scope();
                 var scope = container.create_scope();
                 try {
                 try {
                     var db = scope.resolve<OrmSession>();
                     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 {
                 } finally {
                     scope.release();
                     scope.release();
                 }
                 }
             } catch (Error e) {
             } 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()) {
                     .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();
             var scope = container.create_scope();
             try {
             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 {
             } finally {
                 scope.release();
                 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,
         public async void upsert_subscription(OrmSession db, int64 user_id,
                 string endpoint, string p256dh, string auth) throws Error {
                 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,
         public async void remove_subscription(OrmSession db, int64 user_id,
                 string endpoint) throws Error {
                 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.
          * client-side against the browser's own subscription.
          */
          */
         public async bool user_subscribed(OrmSession db, int64 user_id) throws Error {
         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
          * up in the list: present => THIS browser is subscribed (show
          * "Turn off"), absent => "Enable" (+ a count of the others).
          * "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];
             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;
             return fingerprints;
         }
         }
@@ -871,6 +486,10 @@ namespace ClankerSaddle.Services {
             return builder.str;
             return builder.str;
         }
         }
 
 
+        // ------------------------------------------------------------------
+        // Notify prefs (pure app domain, caller's database scope)
+        // ------------------------------------------------------------------
+
         /** True when the user holds a "Notify me" pref for the task. */
         /** True when the user holds a "Notify me" pref for the task. */
         public async bool has_notify_pref(OrmSession db, int64 user_id,
         public async bool has_notify_pref(OrmSession db, int64 user_id,
                 int64 task_id) throws Error {
                 int64 task_id) throws Error {
@@ -910,60 +529,6 @@ namespace ClankerSaddle.Services {
         // Primitives
         // 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. */
         /** base64url (padded or not) -> bytes; junk decodes empty. */
         public static uint8[] base64url_decode(string text) {
         public static uint8[] base64url_decode(string text) {
             var normalised = new StringBuilder();
             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)");
             raised_payload.set_string_member("text", @"$(block.block_type) raised: $(block.question)");
             yield events.append(EVENT_STATE_CHANGE, EVENT_ACTOR_SYSTEM, EVENT_SCOPE_TASK,
             yield events.append(EVENT_STATE_CHANGE, EVENT_ACTOR_SYSTEM, EVENT_SCOPE_TASK,
                 task_id, raised_payload);
                 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
             // push-subscribed users globally — the goal agent's mirrored
             // blocks stay quiet (the goal-level block owns the
             // blocks stay quiet (the goal-level block owns the
             // notification with the goal's title and URL).
             // notification with the goal's title and URL).
@@ -1287,7 +1287,7 @@ namespace ClankerSaddle.Services {
 
 
             yield release_task(task_id, outcome, false);
             yield release_task(task_id, outcome, false);
             yield pulse.bump(task_id, "task completed");
             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.
             // holding a "Notify me" pref for this task.
             push.notify_opted_in(task_id, PushService.lifecycle_notification(
             push.notify_opted_in(task_id, PushService.lifecycle_notification(
                 ((!)task).title, task_id, "completed", summary));
                 ((!)task).title, task_id, "completed", summary));
@@ -1362,7 +1362,7 @@ namespace ClankerSaddle.Services {
 
 
             yield release_task(task_id, OUTCOME_FAILED, false);
             yield release_task(task_id, OUTCOME_FAILED, false);
             yield pulse.bump(task_id, "task failed");
             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
             // notifies the opted-in users (a human cancellation does
             // not — the cancellator already knows).
             // not — the cancellator already knows).
             push.notify_opted_in(task_id, PushService.lifecycle_notification(
             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
      * 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
      * from the dashboard bell's subscribe flow
      * (`pushManager.subscribe` + the VAPID key from the page state).
      * (`pushManager.subscribe` + the VAPID key from the page state).
      * Validated per {@link PushService.validate_subscription}; the
      * 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
      * and completion on top of the global block events every
      * push-subscribed user gets.
      * 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.
             // per-instance and the endpoint objects do the same.
             var canon = new Services.Canon();
             var canon = new Services.Canon();
             pub.canon_texts = canon.texts().length;
             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
             // 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
             // anywhere (convenience), and this user's endpoint
             // FINGERPRINTS — the client compares its own subscription's
             // FINGERPRINTS — the client compares its own subscription's
             // fingerprint against the list, so each browser shows its
             // fingerprint against the list, so each browser shows its
             // own truth.
             // 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
             // Not a DashboardPublic property: GObjectMapping cannot
             // serialise array-shaped values, so the fingerprint list
             // serialise array-shaped values, so the fingerprint list
             // rides the props dictionary like every other list here.
             // 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
             // the dashboard's bell strip derives its per-browser verdict
             // from, so /profile owns the preference UI while the
             // from, so /profile owns the preference UI while the
             // dashboard keeps only the compact strip. Reuses 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_subscribe = action_registry.author<PushSubscribeAction>();
             view.push_unsubscribe = action_registry.author<PushUnsubscribeAction>();
             view.push_unsubscribe = action_registry.author<PushUnsubscribeAction>();
             view.push_test = action_registry.author<PushTestAction>();
             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.refresh = actions.author_private<RefreshTaskDataAction, TaskActionPrivate>(sealed, "GET");
             pub.attach_rule = actions.author_private<AttachTaskRuleAction, TaskActionPrivate>(sealed);
             pub.attach_rule = actions.author_private<AttachTaskRuleAction, TaskActionPrivate>(sealed);
             pub.attach_capability = actions.author_private<AttachTaskCapabilityAction, 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.
             // user's per-task opt-in for start/failure/completion.
             if (viewer_id > 0) {
             if (viewer_id > 0) {
                 pub.notify_me = yield push.has_notify_pref(db, viewer_id, task.id);
                 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.M0009_TurnCapPark>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0010_ModelConfig>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0010_ModelConfig>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0011_UserPreferences>();
         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<Project>(Project.entity_mapping);
         db.add_entity<ProjectLink>(ProjectLink.entity_mapping);
         db.add_entity<ProjectLink>(ProjectLink.entity_mapping);
         db.add_entity<TaskEntity>(TaskEntity.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<Capability>(Capability.entity_mapping);
         db.add_entity<SaddleCapability>(SaddleCapability.entity_mapping);
         db.add_entity<SaddleCapability>(SaddleCapability.entity_mapping);
         db.add_entity<TaskCapability>(TaskCapability.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<TaskNotifyPref>(TaskNotifyPref.entity_mapping);
         db.add_entity<Goal>(Goal.entity_mapping);
         db.add_entity<Goal>(Goal.entity_mapping);
         db.add_entity<GoalLink>(GoalLink.entity_mapping);
         db.add_entity<GoalLink>(GoalLink.entity_mapping);
@@ -109,14 +114,6 @@ int main(string[] args) {
         application.add_singleton<AdvisorService>();
         application.add_singleton<AdvisorService>();
         application.add_singleton<AdvisorCallback>();
         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>();
         var statum = application.configure_with<StatumConfigurator>();
         statum.global<ClankerSaddle.Services.PulsePublic>(StatePulse.SLOT_TYPE,
         statum.global<ClankerSaddle.Services.PulsePublic>(StatePulse.SLOT_TYPE,
             new ClankerSaddle.Services.PulsePublic());
             new ClankerSaddle.Services.PulsePublic());

+ 12 - 2
src/pages/app.js

@@ -702,16 +702,26 @@
                             if (!card) return;
                             if (!card) return;
                             var here = mine !== null && list.indexOf(mine) !== -1;
                             var here = mine !== null && list.indexOf(mine) !== -1;
                             var others = otherBrowserCount();
                             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 onBtn = card.querySelector('[data-bell-on]');
                             var offBtn = card.querySelector('[data-bell-off]');
                             var offBtn = card.querySelector('[data-bell-off]');
                             var help = card.querySelector('[data-bell-help]');
                             var help = card.querySelector('[data-bell-help]');
                             var testBtn = card.querySelector('[data-push-test]');
                             var testBtn = card.querySelector('[data-push-test]');
                             hereNow = here;
                             hereNow = here;
-                            if (onBtn) onBtn.hidden = here;
+                            if (onBtn) onBtn.hidden = here || !keyed;
                             if (offBtn) offBtn.hidden = !here;
                             if (offBtn) offBtn.hidden = !here;
                             if (testBtn) testBtn.hidden = !here;
                             if (testBtn) testBtn.hidden = !here;
                             if (help) {
                             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
                                     help.textContent = others > 0
                                         ? '\u2014 this browser receives question, RFC, review and waiting-on-drafts alerts; '
                                         ? '\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'
                                           + 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)
     // Live dashboard: the pulse subscription (see the entrypoint)
     // re-runs the sealed refresh action whenever task or saddle state
     // re-runs the sealed refresh action whenever task or saddle state
     // changes, so progress, blocks and review requests stream in.
     // 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
     // (window.initPushBell, goal 41 task 3: the dashboard's compact
     // rail strip and /profile's settings card run the same code).
     // rail strip and /profile's settings card run the same code).
     if (window.statum) {
     if (window.statum) {
@@ -244,7 +244,7 @@
                         <p><a href="/saddles">Manage saddles</a></p>
                         <p><a href="/saddles">Manage saddles</a></p>
                     </section>
                     </section>
 
 
-                    <!-- The notification bell (plan 11 task E; per-browser
+                    <!-- The notification bell (per-browser
                          since the user-global flag lied to second
                          since the user-global flag lied to second
                          browsers): THIS browser's own subscription decides
                          browsers): THIS browser's own subscription decides
                          Enable vs Turn off (the JS fingerprints the
                          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"
         "select name from sqlite_master"
         " where type = 'index' and tbl_name = 'events'").fetchall()}
         " where type = 'index' and tbl_name = 'events'").fetchall()}
     conn.close()
     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,"
           " (a fresh boot ran M0001 then the M0002 no-op then M0003,"
           " M0004 understandings, M0005 discoveries, M0006 advisor,"
           " M0004 understandings, M0005 discoveries, M0006 advisor,"
           " M0007 the parking guard, M0008 answer-wake, M0009 the"
           " 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)")
           "no duplicate migration rows (each guarded up() applied once)")
     check({"idx_events_scope", "idx_events_terminal"} <= event_indexes,
     check({"idx_events_scope", "idx_events_terminal"} <= event_indexes,
           "M0003's event reader indexes exist (fresh boot)")
           "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
 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):
 def run(ctx):
@@ -19,35 +21,39 @@ def run(ctx):
         conn = db()
         conn = db()
         try:
         try:
             return conn.execute(
             return conn.execute(
-                "select count(*) from push_subscriptions where endpoint = ?",
+                "select count(*) from spry_push_subscriptions where endpoint = ?",
                 (endpoint,)).fetchone()[0]
                 (endpoint,)).fetchone()[0]
         finally:
         finally:
             conn.close()
             conn.close()
 
 
-    print("== task E: service worker asset ==")
+    print("== service worker asset ==")
     status, sw = http("GET", "/sw.js", timeout=15)
     status, sw = http("GET", "/sw.js", timeout=15)
     check(status == 200 and "addEventListener('push'" in sw
     check(status == 200 and "addEventListener('push'" in sw
           and "notificationclick" in sw and "clients.openWindow" in sw,
           and "notificationclick" in sw and "clients.openWindow" in sw,
           "the service worker serves at the origin root with push + click routing")
           "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")))
     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(
     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(
     derived_public = vapid_private_key.public_key().public_bytes(
         Encoding.X962, PublicFormat.UncompressedPoint)
         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")
     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")),
     check(dash.get("push_subscribed") is False and bool(dash.get("push_subscribe")),
           "the bell starts unsubscribed and carries the subscribe action")
           "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 = ec.generate_private_key(ec.SECP256R1())
     browser_public = browser.public_key().public_bytes(
     browser_public = browser.public_key().public_bytes(
         Encoding.X962, PublicFormat.UncompressedPoint)
         Encoding.X962, PublicFormat.UncompressedPoint)
@@ -72,26 +78,30 @@ def run(ctx):
     act(auth, dash["push_subscribe"],
     act(auth, dash["push_subscribe"],
         {"endpoint": endpoint_main, **subscribe_fields})
         {"endpoint": endpoint_main, **subscribe_fields})
     conn = db()
     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()
                         " where endpoint = ?", (endpoint_main,)).fetchall()
     admin_id = conn.execute("select id from spry_users where username = 'admin'"
     admin_id = conn.execute("select id from spry_users where username = 'admin'"
                             ).fetchone()[0]
                             ).fetchone()[0]
+    stale = conn.execute("select count(*) from sqlite_master where name ="
+                         " 'push_subscriptions'").fetchone()[0]
     conn.close()
     conn.close()
     check(len(rows) == 1 and rows[0][0] == admin_id,
     check(len(rows) == 1 and rows[0][0] == admin_id,
           "a valid subscription stores against the signed-in user")
           "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)
     rotated_auth = os.urandom(16)
     act(auth, dash["push_subscribe"],
     act(auth, dash["push_subscribe"],
         {"endpoint": endpoint_main, "p256dh": subscribe_fields["p256dh"],
         {"endpoint": endpoint_main, "p256dh": subscribe_fields["p256dh"],
          "auth": b64url(rotated_auth)})
          "auth": b64url(rotated_auth)})
     conn = db()
     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()
                         (endpoint_main,)).fetchall()
     conn.close()
     conn.close()
     check(len(rows) == 1 and rows[0][0] == b64url(rotated_auth),
     check(len(rows) == 1 and rows[0][0] == b64url(rotated_auth),
           "re-subscribing an endpoint replaces its keys in place")
           "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"],
     act(auth, slot_public(auth, "/tasks", "tasks")["create"],
         {"project_id": "1", "title": "Push pref task", "priority": "5"})
         {"project_id": "1", "title": "Push pref task", "priority": "5"})
     pref_task = task_id_of("Push pref task")
     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,
     check(task_state(auth, pref_task).get("notify_me") is False,
           "the task slot reflects the opt-out")
           "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
     # Two "browsers" for admin (/main, /gone) and one for another user
     # (id 999, rows seeded directly — push targeting is per user).
     # (id 999, rows seeded directly — push targeting is per user).
     endpoint_gone = push.url("/gone")
     endpoint_gone = push.url("/gone")
@@ -125,8 +135,9 @@ def run(ctx):
     other_auth = os.urandom(16)
     other_auth = os.urandom(16)
     endpoint_other = push.url("/other")
     endpoint_other = push.url("/other")
     conn = db()
     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)))
                  (endpoint_other, b64url(other_public), b64url(other_auth)))
     conn.commit()
     conn.commit()
     conn.close()
     conn.close()
@@ -145,7 +156,6 @@ def run(ctx):
     check(wait_for(lambda: fake.running_task == task1 or False, timeout=30,
     check(wait_for(lambda: fake.running_task == task1 or False, timeout=30,
                    what="task 1 assignment") is not None,
                    what="task 1 assignment") is not None,
           "task 1 dispatches to the fake daemon")
           "task 1 dispatches to the fake daemon")
-    task1_session = fake.session_id
     check(wait_for(lambda: len(push.hits("/main")) > main_before, timeout=15,
     check(wait_for(lambda: len(push.hits("/main")) > main_before, timeout=15,
                    what="first-start push to the opted-in admin") is not None,
                    what="first-start push to the opted-in admin") is not None,
           "first start notifies the opted-in user's subscription")
           "first start notifies the opted-in user's subscription")
@@ -166,14 +176,13 @@ def run(ctx):
     check(len(push.hits("/other")) == other_before,
     check(len(push.hits("/other")) == other_before,
           "completion stays with the opted-in user only")
           "completion stays with the opted-in user only")
     check(wait_for(lambda: subscription_count(endpoint_gone) == 0, timeout=15,
     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 = db()
     conn.execute("insert into tasks (project_id, title, status, priority,"
     conn.execute("insert into tasks (project_id, title, status, priority,"
                  " created_at) values (1, 'Push block host', 'working', 5,"
                  " created_at) values (1, 'Push block host', 'working', 5,"
@@ -205,13 +214,16 @@ def run(ctx):
           and payload["url"] == "/tasks/%d" % push_host,
           and payload["url"] == "/tasks/%d" % push_host,
           "the block payload carries task title + kind, question and click URL (%s)"
           "the block payload carries task title + kind, question and click URL (%s)"
           % payload)
           % payload)
-    ciphertext_len = len(block_hit["body"]) - (21 + 65)
     envelope = block_hit["body"]
     envelope = block_hit["body"]
     record_size = int.from_bytes(envelope[16:20], "big")
     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
     check(claims.get("aud") == "http://127.0.0.1:%d" % push.port
           and claims.get("sub", "").startswith("mailto:")
           and claims.get("sub", "").startswith("mailto:")
           and claims.get("exp", 0) > time.time(),
           and claims.get("exp", 0) > time.time(),
@@ -224,10 +236,10 @@ def run(ctx):
     # 400 regression).
     # 400 regression).
     check(block_hit["headers"].get("content-encoding") == "aes128gcm",
     check(block_hit["headers"].get("content-encoding") == "aes128gcm",
           "the push POST carries Content-Encoding: aes128gcm (RFC 8291)")
           "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", "")
           and block_hit["headers"].get("authorization", "")
           .startswith("vapid t="),
           .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
     # Task 2: only the OTHER user opted in — start + failure reach them
     # alone. Baseline again before the enqueue (first start fires at
     # 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,
                    or False, timeout=30, what="task 2 failed") is not None,
           "task 2 fails")
           "task 2 fails")
     check(wait_for(lambda: len(push.hits("/other")) >= other_before2 + 2,
     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")
           "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_hit = [hit for hit in push.hits("/other")[other_before2:]][0]
     _, started_payload = decrypt_webpush(started_hit, other_browser,
     _, started_payload = decrypt_webpush(started_hit, other_browser,
@@ -266,7 +278,10 @@ def run(ctx):
     check(len(push.hits("/main")) == main_before2,
     check(len(push.hits("/main")) == main_before2,
           "an agent failure never reaches users without the pref")
           "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")
     endpoint_flaky = push.url("/flaky")
     act(auth, dash["push_subscribe"],
     act(auth, dash["push_subscribe"],
         {"endpoint": endpoint_flaky, **subscribe_fields})
         {"endpoint": endpoint_flaky, **subscribe_fields})
@@ -275,12 +290,44 @@ def run(ctx):
     raise_global_block("Flaky delivery question?")
     raise_global_block("Flaky delivery question?")
     check(wait_for(lambda: len(push.hits("/flaky")) >= flaky_before + 2,
     check(wait_for(lambda: len(push.hits("/flaky")) >= flaky_before + 2,
                    timeout=20, what="flaky endpoint retried") is not None,
                    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 ==")
     print("== plan 12: goal notifications — blocks/success/failure always ==")
     # Goal blocks, completion approval and failure push to EVERY
     # Goal blocks, completion approval and failure push to EVERY
     # subscribed user (the mirror on the goal-run task stays quiet).
     # subscribed user (the mirror on the goal-run task stays quiet).
-    push_goal = None
     act(auth, slot_public(auth, "/goals", "goals")["create"], {
     act(auth, slot_public(auth, "/goals", "goals")["create"], {
         "project_id": "1", "title": "Push goal", "brief": "Notifies everyone.",
         "project_id": "1", "title": "Push goal", "brief": "Notifies everyone.",
         "branch_name": "goal/push"})
         "branch_name": "goal/push"})
@@ -378,7 +425,7 @@ def run(ctx):
           "the goal success payload carries the goal title + click URL (%s)"
           "the goal success payload carries the goal title + click URL (%s)"
           % done_goal_payload)
           % done_goal_payload)
 
 
-    print("== task E: unsubscribe removes the subscription ==")
+    print("== unsubscribe removes the subscription ==")
     dash = slot_public(auth, "/", "dashboard")
     dash = slot_public(auth, "/", "dashboard")
     check(dash.get("push_subscribed") is True,
     check(dash.get("push_subscribed") is True,
           "the bell shows subscribed while endpoints exist")
           "the bell shows subscribed while endpoints exist")
@@ -401,9 +448,10 @@ def run(ctx):
         return hashlib.sha256(endpoint.encode()).hexdigest()[:16]
         return hashlib.sha256(endpoint.encode()).hexdigest()[:16]
 
 
     print("== per-browser bell: fingerprint state shape ==")
     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.
     # Fresh admin subscriptions on TWO fake endpoints = two browsers.
     push2 = FakePushService()
     push2 = FakePushService()
     push2.serve()
     push2.serve()
@@ -474,50 +522,56 @@ def run(ctx):
     set_types = [d.get("type") for d in result if d.get("type") == "set"]
     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")
     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
     push2.status_for["/b"] = 401
     b_before = len(push2.hits("/b"))
     b_before = len(push2.hits("/b"))
     result = act(auth, dash["push_test"], {})
     result = act(auth, dash["push_test"], {})
     message = action_notify(result) or ""
     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(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?")
     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,
                    what="POST to the now-gone endpoint") is not None,
           "the event reaches the endpoint before its 410")
           "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,
     check(wait_for(lambda: len(fingerprints_of(auth)) == 1, timeout=15,
                    what="bell state drops the pruned endpoint") is not None,
                    what="bell state drops the pruned endpoint") is not None,
           "the refreshed bell state drops the pruned endpoint")
           "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:
 LLM), this suite spawns everything it needs:
 
 
 - the clanker-saddle app binary (path from argv[1], or builddir default)
 - 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
 - in-process fake saddle daemons speaking the wire contract: POST
   /register (plan 10: receives {saddle_token, web_base_url}, answers
   /register (plan 10: receives {saddle_token, web_base_url}, answers
   {ok, ssh_public_key, version}), POST /wake (records the wake; the
   {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:
   behind an expander, answered blocks behind an expander; queue screen:
   New Task expander above the filters; dashboard waiting-on-drafts
   New Task expander above the filters; dashboard waiting-on-drafts
   block with links to where Enqueue lives (pages shard)
   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):
 Heredoc completion reports (pages shard):
 
 
@@ -157,7 +163,7 @@ import urllib.parse
 import urllib.request
 import urllib.request
 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
 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
 # 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.
 # does — decrypt the RFC 8291 body and verify the ES256 VAPID JWT.
 import base64
 import base64
@@ -1059,7 +1065,18 @@ class FakeSaddle:
 
 
 
 
 def start_app(extra_env=None):
 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
     config["saddle_callback_base_url"] = WEB
     with open(os.path.join(WORK, "web-config.json"), "w") as handle:
     with open(os.path.join(WORK, "web-config.json"), "w") as handle:
         json.dump(config, 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):
 def b64url(raw):
@@ -1175,8 +1192,10 @@ def b64url_dec(text):
 class FakePushService:
 class FakePushService:
     """In-process Web Push endpoint server: every subscription the tests
     """In-process Web Push endpoint server: every subscription the tests
     register points at a path here (a distinct "browser endpoint").
     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):
     def __init__(self):
         self.requests = []            # [{"path", "headers", "body", "at"}]
         self.requests = []            # [{"path", "headers", "body", "at"}]
@@ -1234,7 +1253,8 @@ class FakePushService:
 
 
 
 
 def decrypt_webpush(request, client_private_key, client_public, auth_secret):
 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."""
     (record_size, payload dict) — the payload builder's exact output."""
     body = request["body"]
     body = request["body"]
     salt = body[:16]
     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_point = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(),
                                                              eph_public)
                                                              eph_public)
     shared = client_private_key.exchange(ec.ECDH(), eph_point)
     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,
     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,
     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)
     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
     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. */
 /* 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 {
 namespace InvercargillSqlInversion {
 	[CCode (cheader_filename = "invercargill-sql-inversion.h")]
 	[CCode (cheader_filename = "invercargill-sql-inversion.h")]
 	public class DatabaseConfigurator : GLib.Object {
 	public class DatabaseConfigurator : GLib.Object {

+ 2 - 1
vapi/spry-0.2.deps

@@ -1,2 +1,3 @@
 statum-0.1
 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. */
 /* spry-0.2.vapi generated by valac 0.56.19, do not modify. */
 
 
-
+[CCode (gir_namespace = "spry", gir_version = "0.2")]
 namespace Spry {
 namespace Spry {
 	namespace Actions {
 	namespace Actions {
 		[CCode (cheader_filename = "spry-0.2.h")]
 		[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_date_of_birth_message { get; }
 			protected virtual string invalid_email_message { get; }
 			protected virtual string invalid_email_message { get; }
 			protected virtual string name_required_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 password_too_short_message { get; }
 			protected virtual string self_alter_message { get; }
 			protected virtual string self_alter_message { get; }
 			protected virtual string update_failed_message { get; }
 			protected virtual string update_failed_message { get; }
@@ -31,9 +33,24 @@ namespace Spry {
 			protected virtual string login_uri { get; }
 			protected virtual string login_uri { get; }
 			protected virtual string password_changed_message { get; }
 			protected virtual string password_changed_message { get; }
 			protected virtual string password_mismatch_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 password_too_short_message { get; }
 		}
 		}
 		[CCode (cheader_filename = "spry-0.2.h")]
 		[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 class DeleteUserAction : Spry.Actions.UserManagementAction {
 			public DeleteUserAction ();
 			public DeleteUserAction ();
 			public override async Statum.DirectiveBuilder handle () throws GLib.Error;
 			public override async Statum.DirectiveBuilder handle () throws GLib.Error;
@@ -73,6 +90,20 @@ namespace Spry {
 				public string surname { get; private set; }
 				public string surname { get; private set; }
 				public string username { 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 {
 			public enum Rejection {
 				USERNAME_TOO_SHORT,
 				USERNAME_TOO_SHORT,
 				INVALID_EMAIL,
 				INVALID_EMAIL,
@@ -82,9 +113,47 @@ namespace Spry {
 			}
 			}
 			public ProfileValidation ();
 			public ProfileValidation ();
 			public static string? duplicate_message (GLib.Error e, string username_message, string email_message, string other_message);
 			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);
 			public static Spry.Actions.ProfileValidation.Fields? validate (Statum.StatumRequest request, out Spry.Actions.ProfileValidation.Rejection rejection);
 		}
 		}
 		[CCode (cheader_filename = "spry-0.2.h")]
 		[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 class RegisterAction : Statum.StatumAction {
 			public RegisterAction ();
 			public RegisterAction ();
 			public override async Statum.DirectiveBuilder handle () throws GLib.Error;
 			public override async Statum.DirectiveBuilder handle () throws GLib.Error;
@@ -98,6 +167,8 @@ namespace Spry {
 			protected virtual string login_uri { get; }
 			protected virtual string login_uri { get; }
 			protected virtual string name_required_message { get; }
 			protected virtual string name_required_message { get; }
 			protected virtual string password_mismatch_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 password_too_short_message { get; }
 			protected virtual string registration_failed_message { get; }
 			protected virtual string registration_failed_message { get; }
 			protected virtual string username_too_short_message { get; }
 			protected virtual string username_too_short_message { get; }
@@ -112,6 +183,13 @@ namespace Spry {
 			protected virtual string user_required_message { get; }
 			protected virtual string user_required_message { get; }
 		}
 		}
 		[CCode (cheader_filename = "spry-0.2.h")]
 		[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 class SetUserEnabledAction : Spry.Actions.UserManagementAction {
 			public SetUserEnabledAction ();
 			public SetUserEnabledAction ();
 			public override async Statum.DirectiveBuilder handle () throws GLib.Error;
 			public override async Statum.DirectiveBuilder handle () throws GLib.Error;
@@ -121,6 +199,28 @@ namespace Spry {
 			protected virtual string user_required_message { get; }
 			protected virtual string user_required_message { get; }
 		}
 		}
 		[CCode (cheader_filename = "spry-0.2.h")]
 		[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 class UserActionPrivate : GLib.Object {
 			public UserActionPrivate ();
 			public UserActionPrivate ();
 			public int64 user_id { get; set; }
 			public int64 user_id { get; set; }
@@ -148,19 +248,31 @@ namespace Spry {
 				public override string name { get; }
 				public override string name { get; }
 				public override uint64 serial { 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")]
 		[CCode (cheader_filename = "spry-0.2.h")]
 		public class UserEntity : GLib.Object {
 		public class UserEntity : GLib.Object {
 			public UserEntity ();
 			public UserEntity ();
 			public static void entity_mapping (InvercargillSql.Orm.EntityMapperBuilder<Spry.Authentication.UserEntity> cfg);
 			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 created { get; set; }
 			public GLib.DateTime date_of_birth { get; set; }
 			public GLib.DateTime date_of_birth { get; set; }
+			public GLib.DateTime effective_sessions_valid_after { get; }
 			public string email { get; set; }
 			public string email { get; set; }
 			public bool enabled { get; set; }
 			public bool enabled { get; set; }
 			public string forename { get; set; }
 			public string forename { get; set; }
 			public int64 id { get; set; }
 			public int64 id { get; set; }
 			public GLib.DateTime modified { get; set; }
 			public GLib.DateTime modified { get; set; }
 			public string password_hash { get; set; }
 			public string password_hash { get; set; }
+			public GLib.DateTime? sessions_valid_after { get; set; }
 			public string surname { get; set; }
 			public string surname { get; set; }
 			public string username { 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 static void projection_mapping (InvercargillSql.Orm.Projections.ProjectionBuilder<Spry.Authentication.UserProjection> cfg) throws GLib.Error;
 			public GLib.DateTime created { get; set; }
 			public GLib.DateTime created { get; set; }
 			public GLib.DateTime date_of_birth { get; set; }
 			public GLib.DateTime date_of_birth { get; set; }
+			public GLib.DateTime effective_sessions_valid_after { get; }
 			public string email { get; set; }
 			public string email { get; set; }
 			public bool enabled { get; set; }
 			public bool enabled { get; set; }
 			public string forename { get; set; }
 			public string forename { get; set; }
@@ -185,22 +298,35 @@ namespace Spry {
 			public GLib.DateTime modified { get; set; }
 			public GLib.DateTime modified { get; set; }
 			public string password_hash { get; set; }
 			public string password_hash { get; set; }
 			public Invercargill.ImmutableLot<string> permissions { owned get; }
 			public Invercargill.ImmutableLot<string> permissions { owned get; }
+			public GLib.DateTime? sessions_valid_after { get; set; }
 			public string surname { get; set; }
 			public string surname { get; set; }
 			public string username { get; }
 			public string username { get; }
 		}
 		}
 		[CCode (cheader_filename = "spry-0.2.h")]
 		[CCode (cheader_filename = "spry-0.2.h")]
 		public class UserService : GLib.Object {
 		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 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.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 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 clear_user_permissions (int64 user_id) throws GLib.Error;
 			public async void delete_user (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 (int64 user_id) throws GLib.Error;
 			public async Spry.Authentication.UserProjection? get_user_by_username (string username) 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 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 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 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 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_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_enabled (int64 user_id, bool enabled) throws GLib.Error;
 			public async void set_user_permission (int64 user_id, string permission) 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
 			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")]
 	[CCode (cheader_filename = "spry-0.2.h")]
 	public class AuthPrivate : GLib.Object {
 	public class AuthPrivate : GLib.Object {
 		public AuthPrivate ();
 		public AuthPrivate ();
@@ -242,8 +655,10 @@ namespace Spry {
 		public static Spry.CurrentUser current (Statum.StatumRequest request);
 		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 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 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 bool permission_matches (string[] patterns, string permission);
 		public static void register_actions (Statum.StatumConfigurator cfg);
 		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_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");
 		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 class SpryModule : GLib.Object, Inversion.Module {
 		public SpryModule ();
 		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 string author_string (string @namespace, string data);
 			public Invercargill.Properties read_properties (string @namespace, string blob) throws GLib.Error;
 			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 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")]
 		[CCode (cheader_filename = "statum-0.1.h")]
 		public class SigningProvider : GLib.Object {
 		public class SigningProvider : GLib.Object {
@@ -22,7 +22,7 @@ namespace Statum {
 			public bool verify (string content, string signature_b64);
 			public bool verify (string content, string signature_b64);
 			public void verify_frame (Statum.Model.FrameDto frame) throws Statum.Cryptography.SigningError;
 			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 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; }
 			public string signer_id { get; set; }
 		}
 		}
 		[CCode (cheader_filename = "statum-0.1.h")]
 		[CCode (cheader_filename = "statum-0.1.h")]
@@ -217,17 +217,39 @@ namespace Statum {
 		public GLib.Type? resolve (string guid);
 		public GLib.Type? resolve (string guid);
 	}
 	}
 	[CCode (cheader_filename = "statum-0.1.h")]
 	[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 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 ChannelEndpoint ();
+		public bool can_accept_connection (string? ip);
 		public bool has_channel (string id);
 		public bool has_channel (string id);
 		public override async void new_connection (Astralis.HttpContext http_context, Astralis.RouteContext route_context, Astralis.SseStream stream);
 		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 void unsubscribe (string id, string key);
+		public Statum.ConnectionTracker connection_tracker { get; private set; }
 		public override uint retry_interval { get; }
 		public override uint retry_interval { get; }
 	}
 	}
 	[CCode (cheader_filename = "statum-0.1.h")]
 	[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 class ChannelSubscriptionEndpoint : GLib.Object, Astralis.Endpoint {
 		public ChannelSubscriptionEndpoint ();
 		public ChannelSubscriptionEndpoint ();
+		public virtual Astralis.HttpResult? authorize (Astralis.HttpContext http_context);
 	}
 	}
 	[CCode (cheader_filename = "statum-0.1.h")]
 	[CCode (cheader_filename = "statum-0.1.h")]
 	public class ClientScript : Statum.ConstantStatumResource {
 	public class ClientScript : Statum.ConstantStatumResource {
@@ -248,6 +270,17 @@ namespace Statum {
 		public override string name { get; }
 		public override string name { get; }
 	}
 	}
 	[CCode (cheader_filename = "statum-0.1.h")]
 	[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 {
 	public abstract class ConstantStatumResource : GLib.Object, Statum.StatumResource {
 		protected ConstantStatumResource ();
 		protected ConstantStatumResource ();
 		public abstract string get_best_encoding (Invercargill.Set<string> supported);
 		public abstract string get_best_encoding (Invercargill.Set<string> supported);
@@ -283,6 +316,7 @@ namespace Statum {
 	[CCode (cheader_filename = "statum-0.1.h")]
 	[CCode (cheader_filename = "statum-0.1.h")]
 	public class EntrypointEndpoint : GLib.Object, Astralis.Endpoint {
 	public class EntrypointEndpoint : GLib.Object, Astralis.Endpoint {
 		public EntrypointEndpoint ();
 		public EntrypointEndpoint ();
+		public virtual Astralis.HttpResult? authorize (Astralis.HttpContext http_context);
 	}
 	}
 	[CCode (cheader_filename = "statum-0.1.h")]
 	[CCode (cheader_filename = "statum-0.1.h")]
 	public class EntrypointMatch : GLib.Object {
 	public class EntrypointMatch : GLib.Object {
@@ -415,6 +449,9 @@ namespace Statum {
 		public void add_resource<T> ();
 		public void add_resource<T> ();
 		public void add_static_page<TPage> () throws GLib.Error;
 		public void add_static_page<TPage> () throws GLib.Error;
 		public void global<TPublic> (string type_name, TPublic initial) 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<TTopic> (string prefix);
 		public void topic_modifier<TModifier,TState> (string prefix);
 		public void topic_modifier<TModifier,TState> (string prefix);
 	}
 	}
@@ -528,7 +565,8 @@ namespace Statum {
 	}
 	}
 	[CCode (cheader_filename = "statum-0.1.h")]
 	[CCode (cheader_filename = "statum-0.1.h")]
 	public errordomain StateError {
 	public errordomain StateError {
-		SLOT_NOT_FOUND
+		SLOT_NOT_FOUND,
+		FRAME_EXPIRED
 	}
 	}
 	[CCode (cheader_filename = "statum-0.1.h")]
 	[CCode (cheader_filename = "statum-0.1.h")]
 	public errordomain StatumError {
 	public errordomain StatumError {