# S014 — Web Push notifications vision.dj non-negotiable, now implemented end to end: RFC 8030 push resource + RFC 8291 aes128gcm encryption + RFC 8292 VAPID, in Zig with no new dependencies. ## What landed - **`core/push.zig`** — VAPID keypair (`{LINJI_HOME}/vapid.key`, created once), subscription store (`push-subscriptions.json`, per account DID, upsert by endpoint), aes128gcm encryption (ephemeral ECDH + HMAC key schedule, single record with 0x02 delimiter), VAPID JWT (ES256 via `zat.jwt.signP256`, aud = push-service origin, sub = mailto:admin@linji.at), and delivery (POST, `vapid t=…,k=…` authorization; 404/410 = `gone` → subscription pruned). - **BFF** — `/api/config` gains `vapidKey`; `POST /api/push/subscribe` + `/api/push/unsubscribe` behind the session cookie. After `POST /api/post`, every subscribed space member except the author gets pushed `{"title":"林集","body":": ","tag":}`. Delivery is synchronous inside the post request (push services answer in well under a second; member counts are small — revisit with a queue if that stops being true). - **zds fork** — `simplespace.listMembers` was authority-only; the BFF needs the roster to fan out, and members can already see each other's posts, so any space member may now read the roster. No scope check on that path: read grants are collection-scoped and the roster is not a collection — membership is the real ACL. - **PWA** — bell button in the sidebar (🔔/🔕): requests permission, subscribes with `applicationServerKey` from `/api/config`, posts the subscription; click again to unsubscribe. SW (`linji-v3`) shows a notification for every push (silent pushes burn Firefox's quota) and focuses/opens the app on click. ## Bugs caught by the scenario - First push-serving attempt silently did nothing: `listMembers` 403'd for non-authorities and `notifySpaceMembers` swallowed it. Fixed in zds (above); the scenario now exercises the full loop (subscribe → another member posts → push → 404 → pruned) against the real store file. ## Environment landmine (pre-existing, not ours) The day's system glibc/gcc upgrade (gcc 16.1.1) ships `crt1.o` with `.sframe` sections using `R_X86_64_PC64`, which zig's linker rejects — plain-native zds builds fail (zds-bench, zds-plc-repair, and `zig build test` included). Workaround, now documented in app0/README: build zds with `-Dtarget=x86_64-linux-gnu.2.36` (zig's bundled CRT). app0 is unaffected (already builds against an explicit gnu version). ## Verification - Unit: RFC 8291 encrypt/decrypt roundtrip (independent decrypt path in the test), store upsert/remove, `originOf`. - Scenario-serve: 40+ checkpoints PASS incl. `vapidKey` in config, 401 without session, upsert, stored row, bob-posts → push → 404 → pruned, idempotent unsubscribe. Unit + scenario-space green. - **Independent cross-validation**: a Node-crypto stub push service verified the VAPID JWT signature (VALID) and decrypted the aes128gcm body back to the exact notification JSON. This is the strongest evidence short of a vendor round-trip: an implementation that shares no code with ours read our wire format. - Limitation (honest): headless Chromium hangs inside `pushManager.subscribe` (FCM registration never resolves — known headless restriction), so browser-vendor delivery (FCM/Mozilla autopush) was not exercised. The browser-side code is the standard subscribe flow; first real-device run should watch the server log for `push:` lines. ## 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).