From a67d1d44010c3e90b780c23f86f12f0ad7a6cad6 Mon Sep 17 00:00:00 2001 From: iacore Date: Fri, 31 Jul 2026 19:12:24 +0800 Subject: [PATCH] S015 log; notification contract: open app guaranteed, closed best-effort vision.dj: guaranteed surface is the open app (SSE + Notification API); Web Push demoted to best-effort on the same toggle. roadmap: S015 entry; s014 log cross-linked. --- log/s014.dj | 7 ++++++- log/s015.dj | 1 + roadmap.dj | 3 +++ vision.dj | 12 +++++++----- 4 files changed, 17 insertions(+), 6 deletions(-) create mode 100644 log/s015.dj diff --git a/log/s014.dj b/log/s014.dj index 2d8b8a7..5aa875d 100644 --- a/log/s014.dj +++ b/log/s014.dj @@ -1 +1,6 @@ -# S014 - Web Push notifications\n\nvision.dj non-negotiable, now implemented end to end: RFC 8030 push\nresource + RFC 8291 aes128gcm encryption + RFC 8292 VAPID, in Zig with\nno new dependencies.\n\n## What landed\n\n- **`core/push.zig`** — VAPID keypair (`{LINJI_HOME}/vapid.key`,\n created once), subscription store (`push-subscriptions.json`, per\n account DID, upsert by endpoint), aes128gcm encryption (ephemeral\n ECDH + HMAC key schedule, single record with 0x02 delimiter), VAPID\n JWT (ES256 via `zat.jwt.signP256`, aud = push-service origin,\n sub = mailto:admin@linji.at), and delivery (POST, `vapid t=…,k=…`\n authorization; 404/410 = `gone` → subscription pruned).\n- **BFF** — `/api/config` gains `vapidKey`; `POST /api/push/subscribe`\n + `/api/push/unsubscribe` behind the session cookie. After\n `POST /api/post`, every subscribed space member except the author gets\n pushed `{"title":"林集","body":": ","tag":}`. Delivery is synchronous inside the post request (push\n services answer in well under a second; member counts are small —\n revisit with a queue if that stops being true).\n- **zds fork** — `simplespace.listMembers` was authority-only; the BFF\n needs the roster to fan out, and members can already see each other's\n posts, so any space member may now read the roster. No scope check on\n that path: read grants are collection-scoped and the roster is not a\n collection — membership is the real ACL.\n- **PWA** — bell button in the sidebar (🔔/🔕): requests permission,\n subscribes with `applicationServerKey` from `/api/config`, posts the\n subscription; click again to unsubscribe. SW (`linji-v3`) shows a\n notification for every push (silent pushes burn Firefox's quota) and\n focuses/opens the app on click.\n\n## Bugs caught by the scenario\n\n- First push-serving attempt silently did nothing: `listMembers` 403'd\n for non-authorities and `notifySpaceMembers` swallowed it. Fixed in\n zds (above); the scenario now exercises the full loop (subscribe →\n another member posts → push → 404 → pruned) against the real store\n file.\n\n## Environment landmine (pre-existing, not ours)\n\nThe day's system glibc/gcc upgrade (gcc 16.1.1) ships `crt1.o` with\n`.sframe` sections using `R_X86_64_PC64`, which zig's linker rejects —\nplain-native zds builds fail (zds-bench, zds-plc-repair, and\n`zig build test` included). Workaround, now documented in app0/README:\nbuild zds with `-Dtarget=x86_64-linux-gnu.2.36` (zig's bundled CRT).\napp0 is unaffected (already builds against an explicit gnu version).\n\n## Verification\n\n- Unit: RFC 8291 encrypt/decrypt roundtrip (independent decrypt path in\n the test), store upsert/remove, `originOf`.\n- Scenario-serve: 40+ checkpoints PASS incl. `vapidKey` in config, 401\n without session, upsert, stored row, bob-posts → push → 404 → pruned,\n idempotent unsubscribe. Unit + scenario-space green.\n- **Independent cross-validation**: a Node-crypto stub push service\n verified the VAPID JWT signature (VALID) and decrypted the aes128gcm\n body back to the exact notification JSON. This is the strongest\n evidence short of a vendor round-trip: an implementation that shares\n no code with ours read our wire format.\n- Limitation (honest): headless Chromium hangs inside\n `pushManager.subscribe` (FCM registration never resolves — known\n headless restriction), so browser-vendor delivery (FCM/Mozilla\n autopush) was not exercised. The browser-side code is the standard\n subscribe flow; first real-device run should watch the server log for\n `push:` lines.\n", path="docs/log/s014.dj")//Writing S014 log \ No newline at end of file +# S014 - Web Push notifications\n\nvision.dj non-negotiable, now implemented end to end: RFC 8030 push\nresource + RFC 8291 aes128gcm encryption + RFC 8292 VAPID, in Zig with\nno new dependencies.\n\n## What landed\n\n- **`core/push.zig`** — VAPID keypair (`{LINJI_HOME}/vapid.key`,\n created once), subscription store (`push-subscriptions.json`, per\n account DID, upsert by endpoint), aes128gcm encryption (ephemeral\n ECDH + HMAC key schedule, single record with 0x02 delimiter), VAPID\n JWT (ES256 via `zat.jwt.signP256`, aud = push-service origin,\n sub = mailto:admin@linji.at), and delivery (POST, `vapid t=…,k=…`\n authorization; 404/410 = `gone` → subscription pruned).\n- **BFF** — `/api/config` gains `vapidKey`; `POST /api/push/subscribe`\n + `/api/push/unsubscribe` behind the session cookie. After\n `POST /api/post`, every subscribed space member except the author gets\n pushed `{"title":"林集","body":": ","tag":}`. Delivery is synchronous inside the post request (push\n services answer in well under a second; member counts are small —\n revisit with a queue if that stops being true).\n- **zds fork** — `simplespace.listMembers` was authority-only; the BFF\n needs the roster to fan out, and members can already see each other's\n posts, so any space member may now read the roster. No scope check on\n that path: read grants are collection-scoped and the roster is not a\n collection — membership is the real ACL.\n- **PWA** — bell button in the sidebar (🔔/🔕): requests permission,\n subscribes with `applicationServerKey` from `/api/config`, posts the\n subscription; click again to unsubscribe. SW (`linji-v3`) shows a\n notification for every push (silent pushes burn Firefox's quota) and\n focuses/opens the app on click.\n\n## Bugs caught by the scenario\n\n- First push-serving attempt silently did nothing: `listMembers` 403'd\n for non-authorities and `notifySpaceMembers` swallowed it. Fixed in\n zds (above); the scenario now exercises the full loop (subscribe →\n another member posts → push → 404 → pruned) against the real store\n file.\n\n## Environment landmine (pre-existing, not ours)\n\nThe day's system glibc/gcc upgrade (gcc 16.1.1) ships `crt1.o` with\n`.sframe` sections using `R_X86_64_PC64`, which zig's linker rejects —\nplain-native zds builds fail (zds-bench, zds-plc-repair, and\n`zig build test` included). Workaround, now documented in app0/README:\nbuild zds with `-Dtarget=x86_64-linux-gnu.2.36` (zig's bundled CRT).\napp0 is unaffected (already builds against an explicit gnu version).\n\n## Verification\n\n- Unit: RFC 8291 encrypt/decrypt roundtrip (independent decrypt path in\n the test), store upsert/remove, `originOf`.\n- Scenario-serve: 40+ checkpoints PASS incl. `vapidKey` in config, 401\n without session, upsert, stored row, bob-posts → push → 404 → pruned,\n idempotent unsubscribe. Unit + scenario-space green.\n- **Independent cross-validation**: a Node-crypto stub push service\n verified the VAPID JWT signature (VALID) and decrypted the aes128gcm\n body back to the exact notification JSON. This is the strongest\n evidence short of a vendor round-trip: an implementation that shares\n no code with ours read our wire format.\n- Limitation (honest): headless Chromium hangs inside\n `pushManager.subscribe` (FCM registration never resolves — known\n headless restriction), so browser-vendor delivery (FCM/Mozilla\n autopush) was not exercised. The browser-side code is the standard\n subscribe flow; first real-device run should watch the server log for\n `push:` lines.\n", path="docs/log/s014.dj")//Writing S014 log +## Later (same day) + +Scope narrowed (S015): closed-app delivery is best-effort, not a +commitment; the guaranteed notification surface is the open app (SSE + +Notification API). See [s015](s015.dj). diff --git a/log/s015.dj b/log/s015.dj new file mode 100644 index 0000000..dcc7f53 --- /dev/null +++ b/log/s015.dj @@ -0,0 +1 @@ +# S015 - SSE updates + open-app notifications\n\nTwo user decisions (2026-07-31), one slice:\n\n1. **Notification contract**: notifications are guaranteed while the app\n is **open**; when the app is closed we don't guarantee delivery\n (S014's Web Push stays, best-effort).\n2. **Updates over SSE**, not polling.\n\n## What landed\n\n- **`GET /api/events?uri=`** (BFF): an SSE stream per connection\n (httpz `startEventStream`, one thread each). The server polls\n `readSpace` every 3s **as the session user** — so the ACL stays zds's\n own — and emits `event: update` when the post-set hash changes;\n `: hb` heartbeat every ~15s keeps proxies honest and detects dead\n clients (write failure ends the thread). Watching zds server-side is\n the load-bearing choice: CLI bots post straight to zds and never\n touch the BFF, yet their messages still reach every open app.\n- **SpacePage**: the 5s `setInterval` is gone. `EventSource` drives\n `refresh()`; a permanently closed stream (401/403) falls back to slow\n polling (15s).\n- **Open-app notifications**: `refresh()` diffs post URIs against a\n baseline (first load never notifies) and raises\n `new Notification(\"林集\", …)` for new posts **from others**, only when\n `document.hidden` — the visible app already shows the update.\n- **Bell semantics**: \"on\" now means *notification permission granted*\n (open-app notifications work immediately). The background push\n subscription is attempted in the same click but wrapped — vendor\n failure changes nothing.\n\n## Verification\n\n- scenario-serve: new checkpoints — `/api/events` 401 without session;\n a background `curl -N` stream receives `event: update` after another\n member posts. PASS; unit + scenario-space green.\n- Real browser: alice's open app showed a message posted **by bob via\n the CLI** (no BFF write path involved) within one server poll cycle.\n- Not E2E-verified: the `document.hidden` Notification itself (headless\n tabs can't be truly hidden; the code path is the standard\n Notification API behind a permission check).\n", path="docs/log/s015.dj")//Writing S015 log \ No newline at end of file diff --git a/roadmap.dj b/roadmap.dj index b4cf5d4..9fc6152 100644 --- a/roadmap.dj +++ b/roadmap.dj @@ -99,6 +99,9 @@ would be what fails. - **S014** v - Web Push end to end: VAPID + RFC 8291 in `core/push.zig`, subscribe API, post-trigger fan-out (zds roster opened to members), PWA bell + SW handler. [log](log/s014.dj) +- **S015** v - SSE updates (`/api/events`, server watches zds as the + session user; catches CLI-bot writes) + open-app notifications; + closed-app delivery = best-effort. [log](log/s015.dj) ## Open questions diff --git a/vision.dj b/vision.dj index b702825..06bf3a7 100644 --- a/vision.dj +++ b/vision.dj @@ -69,11 +69,13 @@ demo-ware — solid enough that we actually live here. ## Non-negotiables: notifications. Android: optional - **Notifications are required.** A community tool that doesn't reach you - doesn't get used. Mechanism settled: **Web Push API on the PWA** (works - in Firefox and Chrome on Android without install; pushes must always - show a visible notification to dodge Firefox's silent-push quota; iOS - Safari needs the PWA installed). This IS the notification plan — not a - stopgap. + doesn't get used. The guaranteed surface (2026-07-31): **the open app** + — the server watches zds and streams changes over SSE, and the app + raises a Notification for new posts when the tab isn't focused. When + the app is **closed** we don't guarantee delivery: Web Push (VAPID + + RFC 8291) is implemented and best-effort, on the same bell toggle. + iOS Safari needs the PWA installed; pushes must always show a visible + notification (Firefox silent-push quota). - **Android is optional** (2026-07-31): good to have, not mandatory. The PWA + Web Push covers the need; a native shell (FCM, mainland devices without Google services) is only revisited if the PWA proves -- 2.51.2