Browse Source

Docs: tell the Spry.Push web push story

README's Browser notifications section still described the deleted
manual stack (boot-time keygen into a dead vapid config section,
SADDLE_VAPID_SUBJECT, dispatcher-tick five-attempt retry). It now
documents the real story: keys come from spry keys (statum pairs and
the spry VAPID pair in web-config.json, pair-repair rotation with a
loud warning), vapid_subject should be a real mailto: contact, an
unkeyed app skips sends loudly and the bell hides Enable, keys written
after boot are picked up without a restart, subscriptions live in
spry_push_subscriptions, and delivery retries inline (two attempts,
250 ms apart) with 404/410/401/403 sweeps. The earlier spry keys
mention now names the push keys too.

docs/spry-conversion.md gains a Web Push section describing the
app-side contract: SpryModule delivers the store (PushService over
spry_push_subscriptions) and the sender (PushSender); our PushService
is the thin app layer (payload builders, audiences, notify prefs,
fingerprints, send_test); sw.js is app-owned at /sw.js with the
{title, body, url} payload; the bell (initPushBell) is our UI contract
and Spry's spry-push.js is deliberately not used. The doc header is
de-scoped from the UI/UX goal to the standing conventions doc.
saddle-ClankerSaddle 11 hours ago
parent
commit
0b60ee80e0
2 changed files with 88 additions and 17 deletions
  1. 25 13
      README.md
  2. 63 4
      docs/spry-conversion.md

+ 25 - 13
README.md

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

+ 63 - 4
docs/spry-conversion.md

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