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

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

+ 63 - 4
docs/spry-conversion.md

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