Просмотр исходного кода

Adopt Spry.Push for Web Push: delete the manual stack (goal 47 T1)

Our hand-rolled Web Push implementation (own VAPID keygen persisting
into a "vapid" config section, own RFC 8291 crypto behind an OpenSSL C
shim, in-memory queue with dispatcher-tick retry) never worked. Spry
0.4 ships a reviewed stack: Locksmith RFC 8291/8292 crypto, a scoped
subscription store (spry_push_subscriptions) and a singleton sender
with inline bounded retry and dead-endpoint sweeping. This deletes our
plumbing and keeps only the app domain.

Deleted:
- src/Push/ OpenSSL shim and all meson wiring (push_crypto, libcrypto,
  the src/Push include dir).
- The queue/flush/deliver machinery, SendJob/PendingTarget, hkdf,
  VAPID keygen/persist, the [CCode] extern crypto decls and the
  dispatcher's push flush tick.
- The PushSubscription entity (table now owned by Spry's M0003).
- The key-scope boot block in main.vala: the app no longer generates
  key material at boot; keys come from `spry keys` ("spry" config
  section) and unkeyed means Spry logs its skip banner while pages
  render vapid_public_key="" (the bell hides its enable button).

Kept (pure app domain, now in the smaller PushService): the payload
builders and PushNotification shape, notify-pref CRUD over
task_notify_prefs, endpoint fingerprints, our subscribe validation
wording, and the bell's test probe contract (PushTestReport mapping
from Spry's DeliveryOutcome: DELIVERED->delivered, GONE/UNAUTHORIZED->
pruned, RETRYABLE/SKIPPED->failed with truthful first_failure text).

Delivery semantics are Spry's now: notify_global/notify_opted_in
resolve the audience (DISTINCT users over spry_push_subscriptions, or
the task's pref holders) and fire-and-forget a
PushSender.send_to_user per user through a fresh container scope per
dispatch (TTL 60s, matching the old queue's freshness intent).
vapid_public_key() is an instance method resolving the sender.

M0012_ProviderPushSubscriptions (clanker-saddle:12) moves existing
rows: INSERT OR IGNORE into spry_push_subscriptions then DROP the old
table; down() recreates the M0001 shape empty (one-way copy,
documented). The migration declares dependencies = {"spry-authentication"}
because the runner's namespace-first Kahn queue otherwise interleaves
clanker-saddle:12 BEFORE spry-authentication:3 on a fresh database
(both orderings verified empirically on scratch databases).

vapi/ overlay refreshed from the system 0.4.0 vapis (the old pins
predated Spry.Push entirely; the .deps now also carries locksmith-1
and libsoup-3.0).

Verified on scratch databases: clean build; fresh boot migrates in
the pinned order with spry_push_subscriptions present; spry-keys
config surfaces the 87-char key in dashboard/profile state; subscribe
stores a row (re-subscribe replaces), unsubscribe removes it,
validation wording unchanged; the test probe against a live local
receiver delivered an RFC 8291 body that Locksmith's own decrypt
returned as our exact test payload, with a valid ES256 VAPID JWT
(aud = endpoint origin); the upgrade path migrates a seeded legacy
row with its timestamp and drops the old table; notify-pref toggling
unchanged. test/bundles/push.py still asserts the old world and is
rewritten by T2.
task-320 14 часов назад
Родитель
Сommit
807bcb651d

+ 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 - 29
src/Data/Entities.vala

@@ -593,33 +593,6 @@ 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
      * 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
      * task's first start, failure and completion notify the opted-in
@@ -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 {
 
 

+ 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 hand-rolled
+         * `push_subscriptions` table (plan 11 task E) 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

+ 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

+ 220 - 637
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` (goal 47): 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;
+                    if (failed > 0) {
+                        warning("[ClankerSaddle] Push notification %s:"
+                            + " %d/%d user(s) not reached",
+                            notification.title, failed, delivered + failed);
                     }
                     }
-                    queue_lock.lock();
-                    while (!jobs.is_empty()) {
-                        queued.push_tail(jobs.pop_head());
-                    }
-                    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())
-                });
+                ids += row.user_id;
             }
             }
-            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;
-                }
-            }
-            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,30 @@ 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 (the same order the old store
+                // gave).
+                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 +487,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,43 +530,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). */
         /** Standard base64 -> base64url (unpadded). */
         public static string base64url_encode(uint8[] data) {
         public static string base64url_encode(uint8[] data) {
             string encoded = Base64.encode(data);
             string encoded = Base64.encode(data);

+ 4 - 2
src/entrypoints/HomeEntrypoint.vala

@@ -421,12 +421,14 @@ namespace ClankerSaddle {
             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 (plan 11 task E; 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>();

+ 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 (goal 47): 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());

+ 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 {