feat(sse): live library count badges via SSE library.counts events (bookshelf-t3z2w.4) #1231

Merged
zombor merged 2 commits from bd-bookshelf-t3z2w.4 into main 2026-07-26 13:28:01 +00:00
Owner

Summary

  • Adds library.counts SSE event kind, emitted per-user (multi-user isolated via TargetUserID) whenever a library's book count changes — on scan completion (from inside the RunScan activity, replay-safe) and on library creation (from createHandler)
  • Server-side: new GetLibraryUserIDs sqlc query (reverse lookup: library→entitled user IDs), CountBooksInLibrary query, sse.NotifyLibraryCounts curried helper (fail-closed: errors logged and dropped)
  • Browser-side: library_counts_push_controller.js Stimulus controller mounts on the sidebar <ul>, listens to /events SSE, and updates .nav-count badges in-place on matching <li data-library-id> elements; abbrev mirrors the Go tmpl.Abbrev function exactly
  • No second EventSource opened per page (one per controller mount point)
  • 100% Go unit-test coverage + 100% JS branch/statement coverage; 7 Go specs + 17 JS specs added

Test plan

  • make test — all Go packages green
  • npm run coverage — 100% statements + branches
  • go build ./... — clean compile
  • make lint — zero new issues in this worktree

Closes bead bookshelf-t3z2w.4 on merge.

## Summary - Adds `library.counts` SSE event kind, emitted per-user (multi-user isolated via `TargetUserID`) whenever a library's book count changes — on scan completion (from inside the `RunScan` activity, replay-safe) and on library creation (from `createHandler`) - Server-side: new `GetLibraryUserIDs` sqlc query (reverse lookup: library→entitled user IDs), `CountBooksInLibrary` query, `sse.NotifyLibraryCounts` curried helper (fail-closed: errors logged and dropped) - Browser-side: `library_counts_push_controller.js` Stimulus controller mounts on the sidebar `<ul>`, listens to `/events` SSE, and updates `.nav-count` badges in-place on matching `<li data-library-id>` elements; abbrev mirrors the Go `tmpl.Abbrev` function exactly - No second EventSource opened per page (one per controller mount point) - 100% Go unit-test coverage + 100% JS branch/statement coverage; 7 Go specs + 17 JS specs added ## Test plan - [x] `make test` — all Go packages green - [x] `npm run coverage` — 100% statements + branches - [x] `go build ./...` — clean compile - [x] `make lint` — zero new issues in this worktree Closes bead bookshelf-t3z2w.4 on merge.
feat(sse): emit library.counts events on scan-complete and library-create (bookshelf-t3z2w.4)
Some checks failed
/ Test Race (pull_request) Successful in 3m9s
/ JS Unit Tests (pull_request) Successful in 3m3s
/ Coverage (pull_request) Successful in 3m37s
/ Lint (pull_request) Successful in 3m39s
/ E2E API (pull_request) Successful in 3m45s
/ Integration (pull_request) Successful in 4m9s
/ E2E Browser (pull_request) Failing after 7m4s
cc085d2d01
- Add GetLibraryUserIDs sqlc query (reverse lookup: library→user IDs)
- Add CountBooksInLibrary sqlc query (per-library book count for SSE payload)
- Add KindLibraryCounts event constant + LibraryCountsPayload struct to sse/event.go
- Add sse.NotifyLibraryCounts: fetches per-library user IDs and book count,
  publishes a TargetUserID-routed event to each entitled user; fail-closed
  (errors logged and dropped, never fail the caller)
- Wire notifyLibraryCounts into BuildScanDeps (called from activity internals,
  replay-safe) and into library.createHandler (library-create path)
- Move sseHub creation before the wfengine block so the scan notifier can
  reference sseHub.Publish at wiring time
- Add NotifyLibraryCounts field to appwire.Deps and library.Deps;
  nil-safe in both the standalone worker binary and test stubs
- Add library_counts_push_controller.js Stimulus controller: subscribes to
  /events SSE, handles library.counts events, updates sidebar .nav-count badges
  live (find-by-data-library-id, create/update/remove span, abbrev number format)
- Add data-controller="library-counts-push" to sidebar <ul>,
  data-library-id="{{.ID}}" to each library <li>
- 100% Go + JS coverage; 7 Go + 17 JS specs added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
zombor force-pushed bd-bookshelf-t3z2w.4 from cc085d2d01
Some checks failed
/ Test Race (pull_request) Successful in 3m9s
/ JS Unit Tests (pull_request) Successful in 3m3s
/ Coverage (pull_request) Successful in 3m37s
/ Lint (pull_request) Successful in 3m39s
/ E2E API (pull_request) Successful in 3m45s
/ Integration (pull_request) Successful in 4m9s
/ E2E Browser (pull_request) Failing after 7m4s
to aa73cda60e
All checks were successful
/ E2E API (pull_request) Successful in 3m4s
/ Test Race (pull_request) Successful in 3m21s
/ JS Unit Tests (pull_request) Successful in 2m3s
/ Coverage (pull_request) Successful in 4m8s
/ Lint (pull_request) Successful in 4m43s
/ Integration (pull_request) Successful in 5m33s
/ E2E Browser (pull_request) Successful in 7m57s
2026-07-25 00:01:42 +00:00
Compare
Author
Owner

Security Review — PR #1231 (SSE slice .4: live library counts)

Adversarial focus: multi-user leak (push + reload), hub routing default-deny, DoS, PII.

Multi-user isolation — VERIFIED CLEAN.

  • Push path: NotifyLibraryCounts (internal/sse/library_counts.go) fans out only to GetLibraryUserIDs = SELECT user_id FROM user_library_mapping WHERE library_id=?, and publishes each event with TargetUserID: uid. The hub's unchanged matches() routes a TargetUserID != 0 event only to sub.userID == TargetUserID, default-deny for everything else.
  • Reload path: the sidebar list filters by GetUserLibraryIDs = SELECT library_id FROM user_library_mapping WHERE user_id=? (internal/app/app.go:685-712), fail-closed on empty mapping. The push scope is the exact inverse of the reload scope over the same user_library_mapping table, so a user receives a live count for library L iff their sidebar renders L. No cross-user leak; no broadcast of per-library counts.
  • userID on the SSE stream comes from users.ClaimsFromContext (session), never a request param (internal/sse/handler.go:31, app.go sseExtractClaims). A stray user_id=0 mapping row would route to nobody (fail-closed).

Hub routing (#2): matches() is unchanged by this diff and remains DEFAULT-DENY. No fail-open regression.

DoS (#3): NotifyLibraryCounts fires once per scan-completion (activity internals — replay-safe, correctly not in the workflow body) and once per library-create (admin-gated). It runs 2 queries + N non-blocking publishes (N bounded by users mapped to the library), not per-SSE-event or per-connection. Subscriptions are capped (512 global / 16 per-user). No cheap DoS vector.

Secrets/PII (#4): LibraryCountsPayload carries only {library_id, count} integers. Clean.

XSS/CSP: controller writes badge.textContent (numeric, no HTML injection); library_id is a server-controlled int64. No inline style=. Clean.

[MINOR] internal/sse/library_counts_test.go:52 — odd trailing actual in assertion
Expect(published, nil).To(HaveLen(2)) passes a spurious nil second actual (asserts nil-is-zero, meaningless here). Use Expect(published).To(HaveLen(2)). No correctness impact.

REVIEW VERDICT: 0 blocker, 0 major, 1 minor

## Security Review — PR #1231 (SSE slice .4: live library counts) Adversarial focus: multi-user leak (push + reload), hub routing default-deny, DoS, PII. **Multi-user isolation — VERIFIED CLEAN.** - Push path: `NotifyLibraryCounts` (`internal/sse/library_counts.go`) fans out only to `GetLibraryUserIDs` = `SELECT user_id FROM user_library_mapping WHERE library_id=?`, and publishes each event with `TargetUserID: uid`. The hub's unchanged `matches()` routes a `TargetUserID != 0` event only to `sub.userID == TargetUserID`, default-deny for everything else. - Reload path: the sidebar list filters by `GetUserLibraryIDs` = `SELECT library_id FROM user_library_mapping WHERE user_id=?` (`internal/app/app.go:685-712`), fail-closed on empty mapping. The push scope is the exact inverse of the reload scope over the same `user_library_mapping` table, so a user receives a live count for library L iff their sidebar renders L. No cross-user leak; no broadcast of per-library counts. - `userID` on the SSE stream comes from `users.ClaimsFromContext` (session), never a request param (`internal/sse/handler.go:31`, `app.go` `sseExtractClaims`). A stray `user_id=0` mapping row would route to nobody (fail-closed). **Hub routing (#2):** `matches()` is unchanged by this diff and remains DEFAULT-DENY. No fail-open regression. **DoS (#3):** `NotifyLibraryCounts` fires once per scan-completion (activity internals — replay-safe, correctly not in the workflow body) and once per library-create (admin-gated). It runs 2 queries + N non-blocking publishes (N bounded by users mapped to the library), not per-SSE-event or per-connection. Subscriptions are capped (512 global / 16 per-user). No cheap DoS vector. **Secrets/PII (#4):** `LibraryCountsPayload` carries only `{library_id, count}` integers. Clean. **XSS/CSP:** controller writes `badge.textContent` (numeric, no HTML injection); `library_id` is a server-controlled int64. No inline `style=`. Clean. [MINOR] internal/sse/library_counts_test.go:52 — odd trailing actual in assertion `Expect(published, nil).To(HaveLen(2))` passes a spurious `nil` second actual (asserts nil-is-zero, meaningless here). Use `Expect(published).To(HaveLen(2))`. No correctness impact. REVIEW VERDICT: 0 blocker, 0 major, 1 minor
Author
Owner

Sidebar screenshot (bookshelf-t3z2w.4)

Left-nav sidebar showing library count badges rendered from DB counts (Fiction: 7, Non-Fiction: 3). The library-counts-push Stimulus controller connects an EventSource to /events and updates these badges live when SSE library.counts events arrive.

sidebar-library-counts

## Sidebar screenshot (bookshelf-t3z2w.4) Left-nav sidebar showing library count badges rendered from DB counts (Fiction: 7, Non-Fiction: 3). The `library-counts-push` Stimulus controller connects an `EventSource` to `/events` and updates these badges live when SSE `library.counts` events arrive. ![sidebar-library-counts](/attachments/77b0b2d0-4f05-4efe-ba53-39645f051d06)
test(sse): fix spurious nil actual in library_counts_test + add sidebar screenshot journey
Some checks failed
/ JS Unit Tests (pull_request) Successful in 1m35s
/ Test Race (pull_request) Successful in 3m43s
/ E2E API (pull_request) Successful in 4m9s
/ Lint (pull_request) Failing after 4m15s
/ Coverage (pull_request) Successful in 5m1s
/ Integration (pull_request) Successful in 6m17s
/ E2E Browser (pull_request) Successful in 7m37s
1eb91fa9a9
- Remove the extraneous nil second actual from Expect(published, nil).To(HaveLen(2));
  the test never folds in an error here — it is a plain published-length check.
- Add e2e/browser/journey_library_counts_sidebar_test.go: an Ordered screenshot
  journey that seeds two libraries (Fiction/7 books, Non-Fiction/3 books), navigates
  to the homepage, and captures the left-nav sidebar showing count badges. Runs
  locally with --ginkgo.focus and posts the PNG to the PR via FORGEJO_TOKEN +
  PULL_REQUEST_NUMBER env vars.

Sidebar screenshot (bookshelf-t3z2w.4)

Left-nav sidebar showing library count badges rendered from DB counts (Fiction: 7, Non-Fiction: 3). The library-counts-push Stimulus controller connects an EventSource to /events and updates these badges live when SSE library.counts events arrive.

sidebar-library-counts

## Sidebar screenshot (bookshelf-t3z2w.4) Left-nav sidebar showing library count badges rendered from DB counts (Fiction: 7, Non-Fiction: 3). The `library-counts-push` Stimulus controller connects an `EventSource` to `/events` and updates these badges live when SSE `library.counts` events arrive. ![sidebar-library-counts](/attachments/7a4ca018-3e17-4475-8ac6-f9025d9a008d)
zombor force-pushed bd-bookshelf-t3z2w.4 from 1eb91fa9a9
Some checks failed
/ JS Unit Tests (pull_request) Successful in 1m35s
/ Test Race (pull_request) Successful in 3m43s
/ E2E API (pull_request) Successful in 4m9s
/ Lint (pull_request) Failing after 4m15s
/ Coverage (pull_request) Successful in 5m1s
/ Integration (pull_request) Successful in 6m17s
/ E2E Browser (pull_request) Successful in 7m37s
to c6804f7fec
Some checks failed
/ JS Unit Tests (pull_request) Successful in 1m17s
/ E2E API (pull_request) Successful in 2m3s
/ Test Race (pull_request) Successful in 3m46s
/ Lint (pull_request) Failing after 3m58s
/ Coverage (pull_request) Successful in 4m16s
/ Integration (pull_request) Successful in 4m55s
/ E2E Browser (pull_request) Successful in 5m36s
2026-07-26 12:22:53 +00:00
Compare

Sidebar screenshot (bookshelf-t3z2w.4)

Left-nav sidebar showing library count badges rendered from DB counts (Fiction: 7, Non-Fiction: 3). The library-counts-push Stimulus controller connects an EventSource to /events and updates these badges live when SSE library.counts events arrive.

sidebar-library-counts

## Sidebar screenshot (bookshelf-t3z2w.4) Left-nav sidebar showing library count badges rendered from DB counts (Fiction: 7, Non-Fiction: 3). The `library-counts-push` Stimulus controller connects an `EventSource` to `/events` and updates these badges live when SSE `library.counts` events arrive. ![sidebar-library-counts](/attachments/bdd4aeaa-97f2-4f39-9f15-3a156f5edeba)
zombor force-pushed bd-bookshelf-t3z2w.4 from c6804f7fec
Some checks failed
/ JS Unit Tests (pull_request) Successful in 1m17s
/ E2E API (pull_request) Successful in 2m3s
/ Test Race (pull_request) Successful in 3m46s
/ Lint (pull_request) Failing after 3m58s
/ Coverage (pull_request) Successful in 4m16s
/ Integration (pull_request) Successful in 4m55s
/ E2E Browser (pull_request) Successful in 5m36s
to fd31f1c675
All checks were successful
/ E2E API (pull_request) Successful in 3m28s
/ Test Race (pull_request) Successful in 3m37s
/ Coverage (pull_request) Successful in 4m10s
/ JS Unit Tests (pull_request) Successful in 34s
/ Lint (pull_request) Successful in 4m31s
/ Integration (pull_request) Successful in 4m57s
/ E2E Browser (pull_request) Successful in 5m9s
2026-07-26 12:33:46 +00:00
Compare
zombor merged commit bf9eda3615 into main 2026-07-26 13:28:01 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
zombor/pergamum!1231
No description provided.