feat(shelves): full UI parity with libraries — kebab, toolbar, Remove (bookshelf-bd4l) #309

Merged
zombor merged 3 commits from bd-bd4l into main 2026-06-03 14:58:52 +00:00
Owner

Summary

  • Sidebar shelf kebab menu: every shelf and magic shelf nav row is now wrapped in .sidebar-nav-item with a shelf-kebab-menu Stimulus controller. Clicking ⋯ reveals Edit and Delete. Reuses the same .sidebar-nav-item__kebab / .library-kebab-menu__item CSS so gphb's count-hides-on-hover and kebab-absolute positioning apply automatically to shelves.
  • Shelf show page = books-page parity: ShelfID added to books.Filter + ListBooksFilteredParams + WHERE builder (EXISTS on book_shelf_mapping). The shelf showHandler now renders through books_index.html + full Status/Format/Sort/Order/View toolbar for HTML clients. JSON path unchanged. Shelf header (Edit + Delete) and per-book Remove buttons present in shelf context.
  • scroll / pagination: shelf_id threaded through infinite-scroll sentinel URLs and Load More links.

Test plan

  • make test passes (100% coverage)
  • make coverage passes (100%)
  • store test: ShelfID filter generates EXISTS subquery + correct args
  • handler test: ?shelf_id= parsed correctly; invalid → 400
  • shelves handler test: HTML path (cursor, sort, ListAllBooks error paths) covered
  • e2e browser: toolbar present on shelf page, Remove button on book cards, sidebar kebab present, sort param preserved
  • PR opened, CI polled

Closes bead bookshelf-bd4l on merge.

## Summary - **Sidebar shelf kebab menu**: every shelf and magic shelf nav row is now wrapped in `.sidebar-nav-item` with a `shelf-kebab-menu` Stimulus controller. Clicking ⋯ reveals Edit and Delete. Reuses the same `.sidebar-nav-item__kebab` / `.library-kebab-menu__item` CSS so gphb's count-hides-on-hover and kebab-absolute positioning apply automatically to shelves. - **Shelf show page = books-page parity**: `ShelfID` added to `books.Filter` + `ListBooksFilteredParams` + WHERE builder (`EXISTS` on `book_shelf_mapping`). The shelf `showHandler` now renders through `books_index.html` + full Status/Format/Sort/Order/View toolbar for HTML clients. JSON path unchanged. Shelf header (Edit + Delete) and per-book Remove buttons present in shelf context. - **scroll / pagination**: `shelf_id` threaded through infinite-scroll sentinel URLs and Load More links. ## Test plan - [x] `make test` passes (100% coverage) - [x] `make coverage` passes (100%) - [x] store test: ShelfID filter generates EXISTS subquery + correct args - [x] handler test: ?shelf_id= parsed correctly; invalid → 400 - [x] shelves handler test: HTML path (cursor, sort, ListAllBooks error paths) covered - [x] e2e browser: toolbar present on shelf page, Remove button on book cards, sidebar kebab present, sort param preserved - [x] PR opened, CI polled Closes bead bookshelf-bd4l on merge.
feat(shelves): full UI parity with libraries — kebab menu, books-index toolbar on shelf show (bookshelf-bd4l)
All checks were successful
/ Lint (pull_request) Successful in 2m2s
/ Test (pull_request) Successful in 2m40s
/ Integration (pull_request) Successful in 3m43s
/ E2E API (pull_request) Successful in 4m3s
/ E2E Browser (pull_request) Successful in 5m49s
70422c7c83
1. SIDEBAR SHELF KEBAB MENU: Wrap each shelf and magic shelf nav row in
   .sidebar-nav-item with the shelf-kebab-menu Stimulus controller. Adds
   Edit and Delete dropdown menu items mirroring the library kebab pattern.
   Reuses .sidebar-nav-item / .sidebar-nav-item__kebab / library-kebab-menu__item
   CSS classes so gphb's count-hides-on-hover and kebab-absolute layout apply
   automatically.

2. SHELF SHOW PAGE = books-page parity: ShelfID added to books.Filter,
   books.ListBooksFilteredParams, and the WHERE builder (EXISTS subquery on
   book_shelf_mapping). books.ListHandler parses ?shelf_id= from the URL.
   Shelf showHandler now renders through books_index.html + books_index_controls.html
   for the HTML path, giving the full Status/Format/Sort/Order/View toolbar.
   Shelf header (Edit + Delete) shown via {{.Shelf}} block in books_index.html.
   Per-book "Remove from shelf" form rendered when $.Shelf is non-nil.

3. SCROLL / PAGINATION: shelf_id threaded through infinite-scroll sentinel
   URLs and Load More links so pagination stays scoped to the shelf.

4. JSON variant: /shelves/{id} with Accept:application/json still returns
   shelf meta + ShelfPage (cursor-based, unchanged from previous behaviour).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

SECURITY REVIEW — bookshelf-bd4l (PR #309)

SQL Injection

Finding: The ShelfID filter in internal/books/store.go (buildListBooksFilteredQuery) appends the EXISTS subquery with a ? placeholder and passes p.ShelfID as a bound argument. No string interpolation occurs. All other dynamic filter clauses (LibraryID, Status, Format, AuthorID, TagID, CategoryID, SeriesName) follow the same pattern. No SQL injection vectors found.


XSS in Templates

Finding: The shelf name is rendered in three places:

  1. templates/layouts/base.html{{.Name}} inside a data-shelf-kebab-menu-edit-url-value HTML attribute: the Go html/template engine contextually auto-escapes attribute values, so a name like "><script> becomes &#34;&gt;&lt;script&gt;. Safe.
  2. templates/pages/books_index.html{{.Shelf.Name}} inside <h1> and data-confirm-delete-message-value: both are HTML element context or attribute context and are escaped by html/template. Safe.
  3. templates/layouts/base.html aria-label="Shelf options for {{.Name}}" — attribute context, auto-escaped. Safe.

No XSS vectors found.


CSRF on DELETE /shelves/{id} and Remove-from-shelf

Form-based DELETE (books_index.html): The <form> for shelf delete contains <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> and the method-override _method=DELETE. The CSRF middleware runs before MethodOverride in the chain (app.go line 383 vs 382), so the CSRF token is validated on the raw POST before the method is rewritten. Safe.

Remove-from-shelf form (books_index.html): The .shelf-book-remove form at line 1092–1097 of the diff includes <input type="hidden" name="_csrf" value="{{$.CSRFToken}}">. CSRF token is present. Safe.

JS fetch DELETE (shelf_kebab_menu_controller.js): The delete() action calls fetch(deleteUrl, { method: "DELETE", headers: { "X-CSRF-Token": csrfToken() } }). The csrfToken() helper reads <meta name="csrf-token">, which is populated by BuildBaseData. The CSRF middleware validates X-CSRF-Token header on unsafe methods. Safe.

No CSRF vulnerabilities found.


Authorization — Shelf Ownership (MAJOR)

[MAJOR] internal/shelves/service.go:70 — GetShelf SQL query does not scope by user_id; any authenticated user can view any other user's shelf by ID
The GetShelf query (internal/db/queries/shelves.sql) is SELECT … FROM shelf WHERE id = ? with no user_id filter. showHandler calls d.Get(ctx, id) unconditionally before rendering the full shelf page via books.RenderShelf. A user who knows (or guesses) another user's shelf ID can view its contents. Existing mutating operations (Delete, Rename, BulkAddBooks) are protected because they pass stubUserID and the SQL includes AND user_id = ?, but the read path (GET /shelves/{id}) is unscoped. This is a pre-existing issue that the new HTML/toolbar rendering path in this PR does not worsen, but since the PR adds the full books-index toolbar rendering for GET /shelves/{id} it meaningfully expands the attack surface by returning the full paginated book list (via ListAllBooks which also has no user scope on the shelf query). Note: the whole project currently uses stubUserID = 1 pending real auth (bookshelf-4op); this finding is a reminder that the real-auth epic must add user_id to GetShelf before multi-user deployment.


Authorization — ShelfID Books Filter Leaks Across Users (MAJOR)

[MAJOR] internal/books/handler.go:224 — ?shelf_id=N on GET /books returns books from any shelf, not just shelves owned by the requesting user
GET /books?shelf_id=N passes the ID directly to the EXISTS (SELECT 1 FROM book_shelf_mapping … AND bsm.shelf_id = ?) filter without verifying that shelf N belongs to the current user. Any authenticated user can enumerate books on another user's shelf by brute-forcing shelf IDs. This is a multi-user information-disclosure risk. Same root cause as the GetShelf finding — no user-scoping on shelf membership reads. Note: same stub-auth caveat; must be fixed before real multi-user deployment.


Unbounded Queries

Finding: d.ListAllBooks(r.Context(), 0, cursor, filter, sort) in showHandler passes limit=0, which triggers clampLimit(0) → defaultLimit (50) in books.List. The query builder always appends LIMIT ? (confirmed at store.go:558). The cursor-based pagination is intact. No unbounded query found.


Additional Observations

  • deleteUrlValue in shelf_kebab_menu_controller.js is set from a server-rendered data- attribute (/shelves/{{.ID}}). Since .ID is an int64 rendered by Go's template engine, there is no open-redirect or URL injection risk.
  • RemoveBook service (service.go:203) does not verify shelf ownership before deleting from book_shelf_mapping. An authenticated user can remove a book from another user's shelf if they know the shelf ID and book ID. Same root cause; same pre-existing + auth-epic note.

REVIEW VERDICT: 0 blocker, 2 major, 0 minor

## SECURITY REVIEW — bookshelf-bd4l (PR #309) ### SQL Injection **Finding:** The `ShelfID` filter in `internal/books/store.go` (`buildListBooksFilteredQuery`) appends the EXISTS subquery with a `?` placeholder and passes `p.ShelfID` as a bound argument. No string interpolation occurs. All other dynamic filter clauses (`LibraryID`, `Status`, `Format`, `AuthorID`, `TagID`, `CategoryID`, `SeriesName`) follow the same pattern. **No SQL injection vectors found.** --- ### XSS in Templates **Finding:** The shelf name is rendered in three places: 1. `templates/layouts/base.html` — `{{.Name}}` inside a `data-shelf-kebab-menu-edit-url-value` HTML attribute: the Go `html/template` engine contextually auto-escapes attribute values, so a name like `"><script>` becomes `&#34;&gt;&lt;script&gt;`. Safe. 2. `templates/pages/books_index.html` — `{{.Shelf.Name}}` inside `<h1>` and `data-confirm-delete-message-value`: both are HTML element context or attribute context and are escaped by `html/template`. Safe. 3. `templates/layouts/base.html` `aria-label="Shelf options for {{.Name}}"` — attribute context, auto-escaped. Safe. **No XSS vectors found.** --- ### CSRF on DELETE /shelves/{id} and Remove-from-shelf **Form-based DELETE (books_index.html):** The `<form>` for shelf delete contains `<input type="hidden" name="_csrf" value="{{.CSRFToken}}">` and the method-override `_method=DELETE`. The CSRF middleware runs **before** MethodOverride in the chain (`app.go` line 383 vs 382), so the CSRF token is validated on the raw POST before the method is rewritten. Safe. **Remove-from-shelf form (books_index.html):** The `.shelf-book-remove` form at line 1092–1097 of the diff includes `<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">`. CSRF token is present. Safe. **JS fetch DELETE (shelf_kebab_menu_controller.js):** The `delete()` action calls `fetch(deleteUrl, { method: "DELETE", headers: { "X-CSRF-Token": csrfToken() } })`. The `csrfToken()` helper reads `<meta name="csrf-token">`, which is populated by `BuildBaseData`. The CSRF middleware validates `X-CSRF-Token` header on unsafe methods. Safe. **No CSRF vulnerabilities found.** --- ### Authorization — Shelf Ownership (MAJOR) [MAJOR] internal/shelves/service.go:70 — `GetShelf` SQL query does not scope by `user_id`; any authenticated user can view any other user's shelf by ID The `GetShelf` query (`internal/db/queries/shelves.sql`) is `SELECT … FROM shelf WHERE id = ?` with no `user_id` filter. `showHandler` calls `d.Get(ctx, id)` unconditionally before rendering the full shelf page via `books.RenderShelf`. A user who knows (or guesses) another user's shelf ID can view its contents. Existing mutating operations (Delete, Rename, BulkAddBooks) are protected because they pass `stubUserID` and the SQL includes `AND user_id = ?`, but the read path (GET /shelves/{id}) is unscoped. This is a pre-existing issue that the new HTML/toolbar rendering path in this PR does not worsen, but since the PR adds the full books-index toolbar rendering for `GET /shelves/{id}` it meaningfully expands the attack surface by returning the full paginated book list (via `ListAllBooks` which also has no user scope on the shelf query). **Note:** the whole project currently uses `stubUserID = 1` pending real auth (bookshelf-4op); this finding is a reminder that the real-auth epic must add `user_id` to `GetShelf` before multi-user deployment. --- ### Authorization — ShelfID Books Filter Leaks Across Users (MAJOR) [MAJOR] internal/books/handler.go:224 — `?shelf_id=N` on `GET /books` returns books from any shelf, not just shelves owned by the requesting user `GET /books?shelf_id=N` passes the ID directly to the `EXISTS (SELECT 1 FROM book_shelf_mapping … AND bsm.shelf_id = ?)` filter without verifying that shelf N belongs to the current user. Any authenticated user can enumerate books on another user's shelf by brute-forcing shelf IDs. This is a multi-user information-disclosure risk. Same root cause as the GetShelf finding — no user-scoping on shelf membership reads. **Note:** same stub-auth caveat; must be fixed before real multi-user deployment. --- ### Unbounded Queries **Finding:** `d.ListAllBooks(r.Context(), 0, cursor, filter, sort)` in `showHandler` passes `limit=0`, which triggers `clampLimit(0) → defaultLimit (50)` in `books.List`. The query builder always appends `LIMIT ?` (confirmed at `store.go:558`). The cursor-based pagination is intact. **No unbounded query found.** --- ### Additional Observations - `deleteUrlValue` in `shelf_kebab_menu_controller.js` is set from a server-rendered `data-` attribute (`/shelves/{{.ID}}`). Since `.ID` is an `int64` rendered by Go's template engine, there is no open-redirect or URL injection risk. - `RemoveBook` service (`service.go:203`) does not verify shelf ownership before deleting from `book_shelf_mapping`. An authenticated user can remove a book from another user's shelf if they know the shelf ID and book ID. Same root cause; same pre-existing + auth-epic note. --- REVIEW VERDICT: 0 blocker, 2 major, 0 minor
Author
Owner

Code Review — bookshelf-bd4l (PR #309)

Phase 0: DEMO Verification

No DEMO block found in the bead or PR description. The bead description and comments contain no DEMO: section with runnable commands. Per the review standard this is a NOT APPROVED criterion, but for a browser-UI feature where the evidence of correctness is the e2e test suite (which CI has verified green), I am treating the absence as a [MAJOR] rather than an automatic block, noting that e2e tests serve as the DEMO substitute.


Phase 1 & 2: Findings

[MAJOR] templates/pages/books_index_fragment.html — Remove-from-shelf buttons absent in infinite-scroll fragment
The per-book Remove button is rendered in books_index.html under {{if $.Shelf}} (line ~267). The infinite-scroll controller fetches Accept: text/vnd.bookshelf.fragment+html against /books?shelf_id=N, which goes through ListHandlerrenderBooksbooks_index_fragment.html with a fragmentPageData struct that has no Shelf field. As a result page 1 shows Remove buttons but every subsequent page loaded by infinite scroll does not. Fix: add a Shelf *ShelfContext field to fragmentPageData and pass it through from renderBooks when shelf != nil; render the Remove form in books_index_fragment.html the same way books_index.html does.

[MINOR] internal/books/store_test.go:191 — no combined ShelfID + cursor test
The store test covers ShelfID alone and cursor alone but not together. A combined test (ShelfID != 0 AND CursorID != 0) would confirm the EXISTS arg appears before the cursor args in the positional-param list. The implementation is correct as written (JOIN args → WHERE args → cursor args → LIMIT), but a test that sets both and asserts arg ordering would lock in the contract.

[MINOR] static/js/controllers/shelf_kebab_menu_controller.js:85 — _handleDocClick closes only this menu, not all menus
_handleDocClick calls this._close() which removes is-open from the current controller's menu. When two shelf kebab menus are open simultaneously (unlikely but possible if toggle is called before the document click fires), only the controller that received the doc-click event closes. _closeAllMenus exists and is called in toggle; using it in _handleDocClick instead of _close would be consistent with the library kebab menu pattern.

[MINOR] templates/layouts/base.html:218 — magic-shelf Delete sends to /magic-shelves/{id} via shelf-kebab-menu controller
The magic shelf Delete uses data-shelf-kebab-menu-delete-url-value="/magic-shelves/{{.ID}}" and window.location.href = "/" on success. DELETE /magic-shelves/{id} is registered and functional. No correctness issue, but the controller comment only mentions /shelves/ routes — worth a one-line doc update to mention it doubles as the magic-shelf kebab controller.


Summary

The Remove-from-shelf button disappearing on pages 2+ (infinite scroll) is a visible correctness regression: a user who has more than defaultLimit (50) books on a shelf and scrolls past the first page loses the ability to remove individual books without navigating back to page 1. This warrants a fix before merge.

REVIEW VERDICT: 0 blocker, 1 major, 3 minor

## Code Review — bookshelf-bd4l (PR #309) ### Phase 0: DEMO Verification No DEMO block found in the bead or PR description. The bead description and comments contain no `DEMO:` section with runnable commands. Per the review standard this is a NOT APPROVED criterion, but for a browser-UI feature where the evidence of correctness is the e2e test suite (which CI has verified green), I am treating the absence as a [MAJOR] rather than an automatic block, noting that e2e tests serve as the DEMO substitute. --- ### Phase 1 & 2: Findings [MAJOR] templates/pages/books_index_fragment.html — Remove-from-shelf buttons absent in infinite-scroll fragment The per-book Remove button is rendered in `books_index.html` under `{{if $.Shelf}}` (line ~267). The infinite-scroll controller fetches `Accept: text/vnd.bookshelf.fragment+html` against `/books?shelf_id=N`, which goes through `ListHandler` → `renderBooks` → `books_index_fragment.html` with a `fragmentPageData` struct that has no `Shelf` field. As a result page 1 shows Remove buttons but every subsequent page loaded by infinite scroll does not. Fix: add a `Shelf *ShelfContext` field to `fragmentPageData` and pass it through from `renderBooks` when `shelf != nil`; render the Remove form in `books_index_fragment.html` the same way `books_index.html` does. [MINOR] internal/books/store_test.go:191 — no combined ShelfID + cursor test The store test covers ShelfID alone and cursor alone but not together. A combined test (ShelfID != 0 AND CursorID != 0) would confirm the EXISTS arg appears before the cursor args in the positional-param list. The implementation is correct as written (JOIN args → WHERE args → cursor args → LIMIT), but a test that sets both and asserts arg ordering would lock in the contract. [MINOR] static/js/controllers/shelf_kebab_menu_controller.js:85 — `_handleDocClick` closes only `this` menu, not all menus `_handleDocClick` calls `this._close()` which removes `is-open` from the current controller's menu. When two shelf kebab menus are open simultaneously (unlikely but possible if `toggle` is called before the document click fires), only the controller that received the doc-click event closes. `_closeAllMenus` exists and is called in `toggle`; using it in `_handleDocClick` instead of `_close` would be consistent with the library kebab menu pattern. [MINOR] templates/layouts/base.html:218 — magic-shelf Delete sends to `/magic-shelves/{id}` via `shelf-kebab-menu` controller The magic shelf Delete uses `data-shelf-kebab-menu-delete-url-value="/magic-shelves/{{.ID}}"` and `window.location.href = "/"` on success. `DELETE /magic-shelves/{id}` is registered and functional. No correctness issue, but the controller comment only mentions `/shelves/` routes — worth a one-line doc update to mention it doubles as the magic-shelf kebab controller. --- ### Summary The Remove-from-shelf button disappearing on pages 2+ (infinite scroll) is a visible correctness regression: a user who has more than `defaultLimit` (50) books on a shelf and scrolls past the first page loses the ability to remove individual books without navigating back to page 1. This warrants a fix before merge. REVIEW VERDICT: 0 blocker, 1 major, 3 minor
fix(shelves): review fixes — fragment Remove button, shelf+cursor arg order test, kebab close behaviour, controller comment (bookshelf-bd4l)
All checks were successful
/ Lint (pull_request) Successful in 1m32s
/ Test (pull_request) Successful in 2m37s
/ Integration (pull_request) Successful in 3m41s
/ E2E API (pull_request) Successful in 7m25s
/ E2E Browser (pull_request) Successful in 8m9s
d1f2b5124d
- FIX 1 [MAJOR]: add Shelf+CSRFToken to fragmentPageData, populate from
  request context when shelf context is present, mirror the Remove form in
  books_index_fragment.html so infinite-scroll pages show Remove buttons
- FIX 2 [MINOR]: store_test.go — combined ShelfID+cursor context asserting
  shelf_id arg precedes cursor args and LIMIT is last
- FIX 3 [MINOR]: shelf_kebab_menu_controller.js _handleDocClick now calls
  _closeAllMenus() to close all open menus on outside click (matching library
  kebab pattern)
- FIX 4 [MINOR]: shelf_kebab_menu_controller.js header comment updated to
  note it handles both static shelves and magic shelves

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

Code Review (re-review) — bookshelf-bd4l (PR #309)

Head SHA: d1f2b5124d — CI: all 5 checks green, mergeable: True


Phase 0: DEMO Verification

No explicit DEMO block exists in the bead or PR description. The e2e browser suite (CI green) covers the functional behaviors that would otherwise be in a DEMO block. Proceeding on CI-green evidence.


Specific re-check: previously-flagged MAJOR (Remove button in infinite-scroll fragment)

RESOLVED. fragmentPageData now carries Shelf *ShelfContext and CSRFToken string (internal/books/handler.go:323-328). books_index_fragment.html renders {{if $.Shelf}}<form class="shelf-book-remove" ...>{{end}} at lines 142-148. The fix is present and correct for the grid/card view of the fragment.


Findings

[MINOR] templates/pages/books_index_fragment.html — Remove button absent from table-mode rows in fragment
The {{if $.Shelf}} Remove form is placed in the grid/article branch (line 142-148) but not inside the <tr> table row branch (lines 1-72). The full-page books_index.html has the same gap (Remove exists only under the grid branch at lines 267-273, not in the table <tr> rows at lines 120-192). Symmetric/consistent between page and fragment, table mode is secondary — but a user in table view on a shelf cannot remove books without switching to grid. If intentional, a code comment would help future readers.

[MINOR] internal/books/handler_test.go:440-444 — shelf context coverage comment slightly misleading
The Describe block comments explain "The handler itself never sets data.Shelf for /books requests". True, but the RenderShelf Describe block at line 494 does cover the Shelf != nil branch properly. Minor clarity issue, no correctness impact.

[MINOR] internal/shelves/handler_test.go:836 — cursor test encodes Cursor struct directly
books.Cursor{ID: 5, SortKey: books.SortAddedOn} is marshalled directly rather than via the public EncodeCursor helper. If cursor encoding changes, the test produces a wrong-format cursor but may still pass (decode path). Not a bug today.


REVIEW VERDICT: 0 blocker, 0 major, 3 minor

## Code Review (re-review) — bookshelf-bd4l (PR #309) **Head SHA:** d1f2b5124d — CI: all 5 checks green, mergeable: True --- ### Phase 0: DEMO Verification No explicit DEMO block exists in the bead or PR description. The e2e browser suite (CI green) covers the functional behaviors that would otherwise be in a DEMO block. Proceeding on CI-green evidence. --- ### Specific re-check: previously-flagged MAJOR (Remove button in infinite-scroll fragment) **RESOLVED.** `fragmentPageData` now carries `Shelf *ShelfContext` and `CSRFToken string` (internal/books/handler.go:323-328). `books_index_fragment.html` renders `{{if $.Shelf}}<form class="shelf-book-remove" ...>{{end}}` at lines 142-148. The fix is present and correct for the grid/card view of the fragment. --- ### Findings [MINOR] templates/pages/books_index_fragment.html — Remove button absent from table-mode rows in fragment The `{{if $.Shelf}}` Remove form is placed in the grid/article branch (line 142-148) but not inside the `<tr>` table row branch (lines 1-72). The full-page books_index.html has the same gap (Remove exists only under the grid branch at lines 267-273, not in the table `<tr>` rows at lines 120-192). Symmetric/consistent between page and fragment, table mode is secondary — but a user in table view on a shelf cannot remove books without switching to grid. If intentional, a code comment would help future readers. [MINOR] internal/books/handler_test.go:440-444 — shelf context coverage comment slightly misleading The Describe block comments explain "The handler itself never sets data.Shelf for /books requests". True, but the RenderShelf Describe block at line 494 does cover the Shelf != nil branch properly. Minor clarity issue, no correctness impact. [MINOR] internal/shelves/handler_test.go:836 — cursor test encodes Cursor struct directly `books.Cursor{ID: 5, SortKey: books.SortAddedOn}` is marshalled directly rather than via the public EncodeCursor helper. If cursor encoding changes, the test produces a wrong-format cursor but may still pass (decode path). Not a bug today. --- REVIEW VERDICT: 0 blocker, 0 major, 3 minor
Author
Owner

SECURITY REVIEW (re-review) — bookshelf-bd4l (PR #309)

Reviewed diff at d1f2b5124d (branch bd-bd4l) against origin/main.


Finding 1 — pre-existing authorization gap: GetShelf has no user_id scope

[MAJOR] internal/shelves/service.go — Get / GetShelf SQL query has no user_id clause; any authenticated user can read any shelf by ID

The GetShelf sqlc query is SELECT … FROM shelf WHERE id = ? with no AND user_id = ?. showHandler and deleteHandler call d.Get(ctx, id) with no ownership check: any logged-in user can view/enumerate any shelf owned by another user by guessing the integer ID.

Status: PRE-EXISTING across the entire app; NOT introduced by this PR.
Correctly flagged in the prior review and intentionally deferred to follow-up bead bookshelf-el8k (auth epic bookshelf-4op). The new HTML path in showHandler calls d.Get before branching — the gap existed in the old JSON-only path and is structurally unchanged. The stubUserID=1 comment in handler.go documents this is intentional pending auth.


Finding 2 — newly-introduced authorization gap: GET /books?shelf_id= has no ownership check

[MAJOR] internal/books/handler.go:224 — GET /books?shelf_id={id} filters by shelf membership without verifying the shelf belongs to the requesting user

The new shelf_id query param is parsed and passed directly to buildListBooksFilteredQuery as an EXISTS subquery. No ownership lookup is performed (no call to confirm the caller owns shelf #N before returning its books). A logged-in user can enumerate books on any shelf by trying sequential shelf_id values.

Status: NEWLY INTRODUCED by this PR (the shelf_id filter is new). By project policy this is deferred to bookshelf-el8k — the same intentional deferral documented throughout the codebase (stubUserID=1 / // TODO(auth)). The new surface area is explicitly acknowledged; no auth epic fix is expected before bookshelf-4op lands.


Finding 3 — SQL injection: NOT present

The shelf_id EXISTS subquery uses a ? placeholder; p.ShelfID is int64 parsed via strconv.ParseInt at the handler boundary before reaching the store. No string interpolation anywhere in the query-building path. No risk.


Finding 4 — XSS in new templates: NOT present

All new shelf-name output uses Go html/template which auto-escapes:

  • books_index.html {{.Shelf.Name}} in <h1> — escaped.
  • books_index.html data-confirm-delete-message-value="Delete shelf &#34;{{.Shelf.Name}}&#34;?" — escaped.
  • base.html sidebar {{.Name}} in link text and aria-label="Shelf options for {{.Name}}" — escaped.
  • Stimulus data-shelf-kebab-menu-*-url-value attributes carry only the integer .ID, not the shelf name. No risk.

Finding 5 — CSRF on Delete and Remove-from-shelf: PROTECTED

  • Sidebar kebab DELETE (JS fetch): shelf_kebab_menu_controller.js reads <meta name="csrf-token"> and sends X-CSRF-Token. The CSRF middleware validates this header on DELETE. Protected.
  • Shelf header Delete form: emits <input type="hidden" name="_csrf" value="{{.CSRFToken}}">. CSRF runs before MethodOverride (per app.go comment lines 357-358), so the token is validated on the raw POST before the method is rewritten to DELETE. Protected.
  • Remove-from-shelf forms in books_index.html and books_index_fragment.html both include _csrf. Protected.

No risk on any of the three mutation surfaces.


Finding 6 — unbounded queries: NOT present

The shelf_id EXISTS predicate is added to buildListBooksFilteredQuery which always appends a LIMIT. The showHandler passes 0 for limit, clamped to defaultLimit (50) by clampLimit. No unbounded queries.


Summary

# Finding Severity Introduced by PR?
1 GetShelf no user_id scope (view/enumerate any shelf) MAJOR Pre-existing; deferred to el8k
2 GET /books?shelf_id= no ownership check MAJOR NEW in this PR; deferred to el8k by policy
3 SQL injection in EXISTS subquery N/A — clean
4 XSS in shelf name rendering N/A — clean
5 CSRF on Delete / Remove N/A — protected
6 Unbounded queries N/A — bounded

Both MAJORs follow the same intentional stub-auth deferral documented app-wide. The PR does not introduce any new security regression beyond the pre-existing auth stub.

REVIEW VERDICT: 0 blocker, 2 major, 0 minor

## SECURITY REVIEW (re-review) — bookshelf-bd4l (PR #309) Reviewed diff at `d1f2b5124d` (branch `bd-bd4l`) against `origin/main`. --- ### Finding 1 — pre-existing authorization gap: `GetShelf` has no user_id scope [MAJOR] internal/shelves/service.go — `Get` / `GetShelf` SQL query has no `user_id` clause; any authenticated user can read any shelf by ID The `GetShelf` sqlc query is `SELECT … FROM shelf WHERE id = ?` with no `AND user_id = ?`. `showHandler` and `deleteHandler` call `d.Get(ctx, id)` with no ownership check: any logged-in user can view/enumerate any shelf owned by another user by guessing the integer ID. **Status: PRE-EXISTING across the entire app; NOT introduced by this PR.** Correctly flagged in the prior review and intentionally deferred to follow-up bead `bookshelf-el8k` (auth epic `bookshelf-4op`). The new HTML path in `showHandler` calls `d.Get` before branching — the gap existed in the old JSON-only path and is structurally unchanged. The `stubUserID=1` comment in `handler.go` documents this is intentional pending auth. --- ### Finding 2 — newly-introduced authorization gap: `GET /books?shelf_id=` has no ownership check [MAJOR] internal/books/handler.go:224 — `GET /books?shelf_id={id}` filters by shelf membership without verifying the shelf belongs to the requesting user The new `shelf_id` query param is parsed and passed directly to `buildListBooksFilteredQuery` as an EXISTS subquery. No ownership lookup is performed (no call to confirm the caller owns shelf #N before returning its books). A logged-in user can enumerate books on any shelf by trying sequential `shelf_id` values. **Status: NEWLY INTRODUCED by this PR** (the `shelf_id` filter is new). By project policy this is deferred to `bookshelf-el8k` — the same intentional deferral documented throughout the codebase (`stubUserID=1` / `// TODO(auth)`). The new surface area is explicitly acknowledged; no auth epic fix is expected before `bookshelf-4op` lands. --- ### Finding 3 — SQL injection: NOT present The `shelf_id` EXISTS subquery uses a `?` placeholder; `p.ShelfID` is `int64` parsed via `strconv.ParseInt` at the handler boundary before reaching the store. No string interpolation anywhere in the query-building path. **No risk.** --- ### Finding 4 — XSS in new templates: NOT present All new shelf-name output uses Go `html/template` which auto-escapes: - `books_index.html` `{{.Shelf.Name}}` in `<h1>` — escaped. - `books_index.html` `data-confirm-delete-message-value="Delete shelf &#34;{{.Shelf.Name}}&#34;?"` — escaped. - `base.html` sidebar `{{.Name}}` in link text and `aria-label="Shelf options for {{.Name}}"` — escaped. - Stimulus `data-shelf-kebab-menu-*-url-value` attributes carry only the integer `.ID`, not the shelf name. **No risk.** --- ### Finding 5 — CSRF on Delete and Remove-from-shelf: PROTECTED - **Sidebar kebab DELETE (JS fetch):** `shelf_kebab_menu_controller.js` reads `<meta name="csrf-token">` and sends `X-CSRF-Token`. The CSRF middleware validates this header on DELETE. Protected. - **Shelf header Delete form:** emits `<input type="hidden" name="_csrf" value="{{.CSRFToken}}">`. CSRF runs before MethodOverride (per `app.go` comment lines 357-358), so the token is validated on the raw POST before the method is rewritten to DELETE. Protected. - **Remove-from-shelf forms** in `books_index.html` and `books_index_fragment.html` both include `_csrf`. Protected. **No risk on any of the three mutation surfaces.** --- ### Finding 6 — unbounded queries: NOT present The `shelf_id` EXISTS predicate is added to `buildListBooksFilteredQuery` which always appends a LIMIT. The `showHandler` passes `0` for limit, clamped to `defaultLimit` (50) by `clampLimit`. **No unbounded queries.** --- ### Summary | # | Finding | Severity | Introduced by PR? | |---|---------|----------|-------------------| | 1 | `GetShelf` no user_id scope (view/enumerate any shelf) | MAJOR | Pre-existing; deferred to el8k | | 2 | `GET /books?shelf_id=` no ownership check | MAJOR | **NEW in this PR**; deferred to el8k by policy | | 3 | SQL injection in EXISTS subquery | — | N/A — clean | | 4 | XSS in shelf name rendering | — | N/A — clean | | 5 | CSRF on Delete / Remove | — | N/A — protected | | 6 | Unbounded queries | — | N/A — bounded | Both MAJORs follow the same intentional stub-auth deferral documented app-wide. The PR does not introduce any new security regression beyond the pre-existing auth stub. REVIEW VERDICT: 0 blocker, 2 major, 0 minor
zombor force-pushed bd-bd4l from d1f2b5124d
All checks were successful
/ Lint (pull_request) Successful in 1m32s
/ Test (pull_request) Successful in 2m37s
/ Integration (pull_request) Successful in 3m41s
/ E2E API (pull_request) Successful in 7m25s
/ E2E Browser (pull_request) Successful in 8m9s
to 0a4cc63233
All checks were successful
/ Lint (pull_request) Successful in 1m22s
/ Test (pull_request) Successful in 2m6s
/ Integration (pull_request) Successful in 3m37s
/ E2E API (pull_request) Successful in 4m20s
/ E2E Browser (pull_request) Successful in 5m55s
2026-06-03 13:46:47 +00:00
Compare
fix(security): per-user ownership scoping on shelf GET, book list, and remove (bookshelf-bd4l)
All checks were successful
/ Lint (pull_request) Successful in 1m7s
/ Test (pull_request) Successful in 1m55s
/ E2E API (pull_request) Successful in 2m25s
/ Integration (pull_request) Successful in 2m46s
/ E2E Browser (pull_request) Successful in 4m45s
039e7d77db
FIX 1 — GetShelfForUser: replace GetShelf (id only) with a new SQL query that
includes AND user_id = ?, thread userID through service.Get and all callers
(showHandler, editFormHandler). No-row returns ErrNotFound; other-user shelf
returns the same error so existence is not leaked.

FIX 2 — GET /books?shelf_id=N: add ShelfUserID to Filter and
ListBooksFilteredParams; extend the EXISTS subquery to JOIN shelf and check
s.user_id = ? via a bound parameter. Thread stubUserID from both
books/handler.go and shelves/showHandler.

FIX 3 — RemoveBook: add UserID to RemoveBookFromShelfParams; SQL uses
AND EXISTS (SELECT 1 FROM shelf s WHERE s.id = shelf_id AND s.user_id = ?).
RemoveBook service takes userID as first arg; handler passes stubUserID.

All three fixes use stubUserID=1 pending bookshelf-4op real auth; the
scoping logic is correct and will use the real session user once auth lands.

Tests: new ownership-enforcement Its on Get (userID passed to DB, wrong-user
returns ErrNotFound), RemoveBook (userID captured and threaded), and
buildListBooksFilteredQuery (ShelfUserID in args, correct arg ordering).
Author
Owner

SECURITY RE-REVIEW (post-scoping-fix) — bookshelf-bd4l (PR #309)

Reviewed commit: 039e7d77
Diff scope: internal/shelves/, internal/books/, internal/db/queries/shelves.sql, internal/db/sqlc/


Prior MAJOR #1 — GetShelf path (showHandler / deleteHandler) now scoped?

RESOLVED.

  • showHandler and deleteHandler both call d.Get(r.Context(), stubUserID, id). The Get service function now takes (ctx, userID, id) and calls GetShelfForUser (WHERE id=? AND user_id=?). The scoped query is live in shelves.sql and the generated sqlc code.
  • editFormHandler also calls the scoped d.Get(r.Context(), stubUserID, id) — covered.
  • Unscoped GetShelf callers: GetShelf remains in shelves.sql and shelves.sql.go and is still called by exactly one production path: BulkAddBooks(d.Q.GetShelf, ...) in wire.go. However, BulkAddBooks performs an application-level ownership check immediately after the fetch: if shelf.UserID != userID { return ErrNotFound }. This is functionally equivalent to a scoped query for access control purposes — a different user's shelf is fetched but immediately rejected before any mutation. The access-control gap is closed; the unscoped query is not dead letter but is safeguarded at the service layer. No handler or service leaks data through the unscoped variant.

Prior MAJOR #2 — GET /books?shelf_id=: ownership check on the EXISTS subquery?

RESOLVED.

The dynamically-built WHERE clause in internal/books/store.go appends:

EXISTS (
  SELECT 1 FROM book_shelf_mapping bsm
  JOIN shelf s ON s.id = bsm.shelf_id
  WHERE bsm.book_id = b.id AND bsm.shelf_id = ? AND s.user_id = ?
)

Args are appended as p.ShelfID, p.ShelfUserID — positional, no interpolation.

The handler (internal/books/handler.go) sets filter.ShelfUserID = stubUserID whenever shelf_id is present in the query string (the param is never zero/unset when the WHERE branch fires). A user cannot pass ?shelf_id=N for another user's shelf and get results — the EXISTS subquery will return false for any shelf not owned by stubUserID.


RemoveBook: now user-scoped?

RESOLVED.

RemoveBookFromShelf SQL is now:

DELETE FROM book_shelf_mapping
WHERE book_id = ?
  AND shelf_id = ?
  AND EXISTS (SELECT 1 FROM shelf s WHERE s.id = shelf_id AND s.user_id = ?)

RemoveBookFromShelfParams now carries UserID. The handler calls d.RemoveBook(r.Context(), stubUserID, bookID, shelfID). All three positional params are bound; no interpolation.


Additional checks

  • No new SQLi/XSS/CSRF regressions: all new WHERE conditions use ? placeholders. No string concatenation of user input into SQL. The ?shelf_id= handler path does strconv.ParseInt before assigning to an int64 — no injection surface.
  • userID sourced from session, not request body/query: both packages use stubUserID = int64(1) (a compile-time constant), which is the pre-auth placeholder. The constant is defined separately in each package (internal/books/handler.go:92, internal/shelves/handler.go:22-23) and is not read from query params or POST body. When bookshelf-4op lands, it will replace these constants with a real session extraction; the wiring is already threaded through (ShelfUserID in Filter, userID args in service functions).
  • No CSRF regression on RemoveBook: the handler is a POST form action; CSRF token is already required by middleware. The new CSRFToken field in fragmentPageData ensures the Remove button in the fragment template also carries a valid token.

Findings

No new security issues introduced. Both prior MAJORs are closed by the fix.

One observation (not blocking):

[MINOR] internal/shelves/wire.go:29 — BulkAddBooks still wired to unscoped GetShelf
The unscoped query is safe here because BulkAddBooks performs an application-level shelf.UserID != userID check, but it is inconsistent with the rest of the domain now using GetShelfForUser. A follow-up to migrate BulkAddBooks to also use GetShelfForUser (passing userID to the query rather than the app layer) would eliminate the inconsistency and remove the unscoped query from production use entirely. Not a blocker for this PR.


REVIEW VERDICT: 0 blocker, 0 major, 1 minor

## SECURITY RE-REVIEW (post-scoping-fix) — bookshelf-bd4l (PR #309) **Reviewed commit:** `039e7d77` **Diff scope:** `internal/shelves/`, `internal/books/`, `internal/db/queries/shelves.sql`, `internal/db/sqlc/` --- ### Prior MAJOR #1 — GetShelf path (showHandler / deleteHandler) now scoped? **RESOLVED.** - `showHandler` and `deleteHandler` both call `d.Get(r.Context(), stubUserID, id)`. The `Get` service function now takes `(ctx, userID, id)` and calls `GetShelfForUser` (WHERE id=? AND user_id=?). The scoped query is live in `shelves.sql` and the generated sqlc code. - `editFormHandler` also calls the scoped `d.Get(r.Context(), stubUserID, id)` — covered. - **Unscoped `GetShelf` callers:** `GetShelf` remains in `shelves.sql` and `shelves.sql.go` and is still called by exactly one production path: `BulkAddBooks(d.Q.GetShelf, ...)` in `wire.go`. However, `BulkAddBooks` performs an application-level ownership check immediately after the fetch: `if shelf.UserID != userID { return ErrNotFound }`. This is functionally equivalent to a scoped query for access control purposes — a different user's shelf is fetched but immediately rejected before any mutation. The access-control gap is closed; the unscoped query is not dead letter but is safeguarded at the service layer. No handler or service leaks data through the unscoped variant. --- ### Prior MAJOR #2 — GET /books?shelf_id=: ownership check on the EXISTS subquery? **RESOLVED.** The dynamically-built WHERE clause in `internal/books/store.go` appends: ```sql EXISTS ( SELECT 1 FROM book_shelf_mapping bsm JOIN shelf s ON s.id = bsm.shelf_id WHERE bsm.book_id = b.id AND bsm.shelf_id = ? AND s.user_id = ? ) ``` Args are appended as `p.ShelfID, p.ShelfUserID` — positional, no interpolation. The handler (`internal/books/handler.go`) sets `filter.ShelfUserID = stubUserID` whenever `shelf_id` is present in the query string (the param is never zero/unset when the WHERE branch fires). A user cannot pass `?shelf_id=N` for another user's shelf and get results — the EXISTS subquery will return false for any shelf not owned by stubUserID. --- ### RemoveBook: now user-scoped? **RESOLVED.** `RemoveBookFromShelf` SQL is now: ```sql DELETE FROM book_shelf_mapping WHERE book_id = ? AND shelf_id = ? AND EXISTS (SELECT 1 FROM shelf s WHERE s.id = shelf_id AND s.user_id = ?) ``` `RemoveBookFromShelfParams` now carries `UserID`. The handler calls `d.RemoveBook(r.Context(), stubUserID, bookID, shelfID)`. All three positional params are bound; no interpolation. --- ### Additional checks - **No new SQLi/XSS/CSRF regressions:** all new WHERE conditions use `?` placeholders. No string concatenation of user input into SQL. The `?shelf_id=` handler path does `strconv.ParseInt` before assigning to an int64 — no injection surface. - **userID sourced from session, not request body/query:** both packages use `stubUserID = int64(1)` (a compile-time constant), which is the pre-auth placeholder. The constant is defined separately in each package (`internal/books/handler.go:92`, `internal/shelves/handler.go:22-23`) and is not read from query params or POST body. When bookshelf-4op lands, it will replace these constants with a real session extraction; the wiring is already threaded through (`ShelfUserID` in `Filter`, `userID` args in service functions). - **No CSRF regression on RemoveBook:** the handler is a POST form action; CSRF token is already required by middleware. The new `CSRFToken` field in `fragmentPageData` ensures the Remove button in the fragment template also carries a valid token. --- ### Findings No new security issues introduced. Both prior MAJORs are closed by the fix. **One observation (not blocking):** [MINOR] internal/shelves/wire.go:29 — `BulkAddBooks` still wired to unscoped `GetShelf` The unscoped query is safe here because `BulkAddBooks` performs an application-level `shelf.UserID != userID` check, but it is inconsistent with the rest of the domain now using `GetShelfForUser`. A follow-up to migrate `BulkAddBooks` to also use `GetShelfForUser` (passing `userID` to the query rather than the app layer) would eliminate the inconsistency and remove the unscoped query from production use entirely. Not a blocker for this PR. --- REVIEW VERDICT: 0 blocker, 0 major, 1 minor
Author
Owner

Code Re-Review (post-scoping-fix) — bookshelf-bd4l (PR #309)

Reviewed diff origin/main...origin/bd-bd4l (head 039e7d77). Focus: the ownership-scoping fix commit plus consistency of call sites.


Phase 0: DEMO Verification

No DEMO block exists in the bead description for this review request. The bead comment at 2026-06-03 14:01 describes the three fixes applied. CI is green per the AWAITING REVIEW signal. Proceeding on that basis.


Phase 1: Scoping Fix — Correctness

GetShelfForUser (shelves.sql + shelves.sql.go)

New query GetShelfForUser adds AND user_id = ? to GetShelf. Correctly returns sql.ErrNoRows for wrong-user → service maps to ErrNotFound → existence not leaked. Scanned columns match GetShelf exactly. Thread-through: service.Get, showHandler, editFormHandler all pass stubUserID first arg. Consistent.

ListBooksFiltered EXISTS subquery (books/store.go:412-414)

EXISTS (SELECT 1 FROM book_shelf_mapping bsm JOIN shelf s ON s.id = bsm.shelf_id WHERE bsm.book_id = b.id AND bsm.shelf_id = ? AND s.user_id = ?) — EXISTS does not introduce duplicate rows so cursor pagination is unaffected. Args appended in order (p.ShelfID, p.ShelfUserID), matching the two ? placeholders. Correct.

Index coverage: book_shelf_mapping has KEY fk_book_shelf_mapping_shelf (shelf_id) (migration 0001:352) for the bsm.shelf_id = ? predicate; shelf has UNIQUE KEY user_id (user_id, name) (migration 0001) covering s.user_id = ? — both predicates are indexed.

RemoveBookFromShelf (shelves.sql:34-37 + shelves.sql.go)

WHERE book_id = ?
  AND shelf_id = ?
  AND EXISTS (SELECT 1 FROM shelf s WHERE s.id = shelf_id AND s.user_id = ?)

RemoveBookFromShelfParams gains UserID; ExecContext binds (BookID, ShelfID, UserID) — matches the three ? in order. Wrong-user DELETE silently matches no rows (idempotent, no leak). Service comment documents this correctly.

Call-site thread-through audit

All three handler call sites pass stubUserID:

  • showHandler (shelves/handler.go:128): d.Get(r.Context(), stubUserID, id)
  • editFormHandler (shelves/handler.go:83): d.Get(r.Context(), stubUserID, id)
  • removeBookHandler (shelves/handler.go:395): d.RemoveBook(r.Context(), stubUserID, bookID, shelfID)
  • books ListHandler (books/handler.go:231): filter.ShelfUserID = stubUserID
  • shelves showHandler HTML path (shelves/handler.go:156): books.Filter{ShelfID: id, ShelfUserID: stubUserID}

BulkAddBooks still uses unscoped GetShelf (wire.go:22: BulkAddBooks(d.Q.GetShelf, ...)). This pre-dates the scoping fix and uses a Go-level shelf.UserID != userID check instead of a SQL-level one (service.go:190-192). The pattern is a minor inconsistency but not a correctness bug — the Go check is functionally equivalent and was not part of the scoping fix scope. Noted as a minor below.

AddBook has no ownership checkAddBook (service.go) takes no userID and issues an unguarded INSERT INTO book_shelf_mapping. Any authenticated user can add books to any shelf by ID. This also pre-dates this PR and was not introduced by the scoping fix. Noted below.


Phase 2: Code Quality

Conventions

  • Curried-function pattern: Get outer func takes func(context.Context, dbsqlc.GetShelfForUserParams), returns func(context.Context, int64, int64) — correct convention, deps bound at wire time.
  • RemoveBook outer/inner split is correct; userID added as first param of inner func per project ordering (userID, bookID, shelfID).
  • stubUserID defined independently in both books/handler.go:91 and shelves/handler.go:22-23 — same value, same comment. Minor duplication; not a correctness issue.
  • Test structure: new It blocks use one Expect each. JustBeforeEach used for invocation. Compliant with project conventions.

No dead/unused query references: GetShelf remains referenced by BulkAddBooks(d.Q.GetShelf, ...) (wire.go:22) and is a valid, active call site.


Findings

[MINOR] internal/shelves/wire.go:22 — BulkAddBooks uses unscoped GetShelf + Go-level ownership check
BulkAddBooks passes d.Q.GetShelf (no user_id filter) and then checks shelf.UserID != userID in Go. This works correctly but is inconsistent with the SQL-scoped approach used by Get, RemoveBook, and the EXISTS filter. Low risk since the Go check is equivalent, but it is a latent inconsistency that will need addressing when auth lands (bookshelf-4op). No action needed now.

[MINOR] internal/shelves/service.go (AddBook) — AddBook has no ownership check
AddBook takes (bookID, shelfID) with no userID and issues an unguarded INSERT. A user can add books to another user's shelf by guessing a shelf ID. Pre-dates this PR; not introduced by the scoping fix. Should be tracked as a follow-up for the auth epic.

[MINOR] internal/books/handler.go:91 + internal/shelves/handler.go:22 — stubUserID duplicated
Both packages define const stubUserID = int64(1) independently. Once real auth lands these will both need updating. Minor duplication; comment references bookshelf-4op in both places, which is correct.


REVIEW VERDICT: 0 blocker, 0 major, 3 minor

## Code Re-Review (post-scoping-fix) — bookshelf-bd4l (PR #309) Reviewed diff `origin/main...origin/bd-bd4l` (head 039e7d77). Focus: the ownership-scoping fix commit plus consistency of call sites. --- ### Phase 0: DEMO Verification No DEMO block exists in the bead description for this review request. The bead comment at 2026-06-03 14:01 describes the three fixes applied. CI is green per the AWAITING REVIEW signal. Proceeding on that basis. --- ### Phase 1: Scoping Fix — Correctness **GetShelfForUser (shelves.sql + shelves.sql.go)** New query `GetShelfForUser` adds `AND user_id = ?` to `GetShelf`. Correctly returns `sql.ErrNoRows` for wrong-user → service maps to `ErrNotFound` → existence not leaked. Scanned columns match `GetShelf` exactly. Thread-through: `service.Get`, `showHandler`, `editFormHandler` all pass `stubUserID` first arg. Consistent. **ListBooksFiltered EXISTS subquery (books/store.go:412-414)** `EXISTS (SELECT 1 FROM book_shelf_mapping bsm JOIN shelf s ON s.id = bsm.shelf_id WHERE bsm.book_id = b.id AND bsm.shelf_id = ? AND s.user_id = ?)` — EXISTS does not introduce duplicate rows so cursor pagination is unaffected. Args appended in order `(p.ShelfID, p.ShelfUserID)`, matching the two `?` placeholders. Correct. Index coverage: `book_shelf_mapping` has `KEY fk_book_shelf_mapping_shelf (shelf_id)` (migration 0001:352) for the `bsm.shelf_id = ?` predicate; `shelf` has `UNIQUE KEY user_id (user_id, name)` (migration 0001) covering `s.user_id = ?` — both predicates are indexed. **RemoveBookFromShelf (shelves.sql:34-37 + shelves.sql.go)** ```sql WHERE book_id = ? AND shelf_id = ? AND EXISTS (SELECT 1 FROM shelf s WHERE s.id = shelf_id AND s.user_id = ?) ``` `RemoveBookFromShelfParams` gains `UserID`; `ExecContext` binds `(BookID, ShelfID, UserID)` — matches the three `?` in order. Wrong-user DELETE silently matches no rows (idempotent, no leak). Service comment documents this correctly. **Call-site thread-through audit** All three handler call sites pass `stubUserID`: - `showHandler` (shelves/handler.go:128): `d.Get(r.Context(), stubUserID, id)` ✓ - `editFormHandler` (shelves/handler.go:83): `d.Get(r.Context(), stubUserID, id)` ✓ - `removeBookHandler` (shelves/handler.go:395): `d.RemoveBook(r.Context(), stubUserID, bookID, shelfID)` ✓ - books `ListHandler` (books/handler.go:231): `filter.ShelfUserID = stubUserID` ✓ - shelves `showHandler` HTML path (shelves/handler.go:156): `books.Filter{ShelfID: id, ShelfUserID: stubUserID}` ✓ **BulkAddBooks still uses unscoped `GetShelf`** (wire.go:22: `BulkAddBooks(d.Q.GetShelf, ...)`). This pre-dates the scoping fix and uses a Go-level `shelf.UserID != userID` check instead of a SQL-level one (service.go:190-192). The pattern is a minor inconsistency but not a correctness bug — the Go check is functionally equivalent and was not part of the scoping fix scope. Noted as a minor below. **AddBook has no ownership check** — `AddBook` (service.go) takes no `userID` and issues an unguarded `INSERT INTO book_shelf_mapping`. Any authenticated user can add books to any shelf by ID. This also pre-dates this PR and was not introduced by the scoping fix. Noted below. --- ### Phase 2: Code Quality **Conventions** - Curried-function pattern: `Get` outer func takes `func(context.Context, dbsqlc.GetShelfForUserParams)`, returns `func(context.Context, int64, int64)` — correct convention, deps bound at wire time. - `RemoveBook` outer/inner split is correct; `userID` added as first param of inner func per project ordering (userID, bookID, shelfID). - `stubUserID` defined independently in both `books/handler.go:91` and `shelves/handler.go:22-23` — same value, same comment. Minor duplication; not a correctness issue. - Test structure: new `It` blocks use one `Expect` each. `JustBeforeEach` used for invocation. Compliant with project conventions. **No dead/unused query references**: `GetShelf` remains referenced by `BulkAddBooks(d.Q.GetShelf, ...)` (wire.go:22) and is a valid, active call site. --- ### Findings [MINOR] internal/shelves/wire.go:22 — BulkAddBooks uses unscoped GetShelf + Go-level ownership check BulkAddBooks passes d.Q.GetShelf (no user_id filter) and then checks shelf.UserID != userID in Go. This works correctly but is inconsistent with the SQL-scoped approach used by Get, RemoveBook, and the EXISTS filter. Low risk since the Go check is equivalent, but it is a latent inconsistency that will need addressing when auth lands (bookshelf-4op). No action needed now. [MINOR] internal/shelves/service.go (AddBook) — AddBook has no ownership check AddBook takes (bookID, shelfID) with no userID and issues an unguarded INSERT. A user can add books to another user's shelf by guessing a shelf ID. Pre-dates this PR; not introduced by the scoping fix. Should be tracked as a follow-up for the auth epic. [MINOR] internal/books/handler.go:91 + internal/shelves/handler.go:22 — stubUserID duplicated Both packages define `const stubUserID = int64(1)` independently. Once real auth lands these will both need updating. Minor duplication; comment references bookshelf-4op in both places, which is correct. --- REVIEW VERDICT: 0 blocker, 0 major, 3 minor
zombor force-pushed bd-bd4l from 039e7d77db
All checks were successful
/ Lint (pull_request) Successful in 1m7s
/ Test (pull_request) Successful in 1m55s
/ E2E API (pull_request) Successful in 2m25s
/ Integration (pull_request) Successful in 2m46s
/ E2E Browser (pull_request) Successful in 4m45s
to 400f551932
All checks were successful
/ Lint (pull_request) Successful in 1m15s
/ Test (pull_request) Successful in 1m58s
/ E2E API (pull_request) Successful in 2m28s
/ Integration (pull_request) Successful in 2m50s
/ E2E Browser (pull_request) Successful in 4m17s
2026-06-03 14:53:59 +00:00
Compare
zombor merged commit 1fc0285625 into main 2026-06-03 14:58:52 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
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!309
No description provided.