Library + LibraryPath CRUD API + UI (bookshelf-axt.1) #18
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-axt.1"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
internal/library/(unit) andinternal/app/(integration with real MySQL)Test plan
make testpasses locally (100% coverage gate)internal/libraryunit tests: service (validation, mapping, error wrapping), handler (all routes, JSON + HTML, error paths)internal/appintegration tests: full CRUD exercising all wiring closures against real MySQLe2e/api/libraries_test.go(e2e build tag)Closes bead bookshelf-axt.1 on merge.
🤖 Generated with Claude Code
Security Review (automated)
Reviewed commit
779ee38— Library + LibraryPath CRUD API + UI.Threat assessment
?placeholders. No hand-written string-interpolated SQL found anywhere in the diff.library_path.pathis stored verbatim with zero validation — no length cap, no canonicalization, no rejection of..sequences or absolute paths that escape a configured root. The scanner (AXT-2) hasn't landed yet so there's no exploit path today, but the input gate is open. See finding #1 below.template.HTML/template.JSraw casts —html/templateauto-escaping is intact for all{{.}}expressions. However,data-confirm-message="Delete library {{.Name}}?"puts user-controlled text into an HTML attribute without the"inside the value being escapable by Go's template engine in that context. See finding #2.TODO-AUTHcallsites are absent — recommend adding a comment atRegisterRoutesso the auth bead knows where to insert middleware.namehas no length cap in Go-sidevalidate(). DB column isvarchar(255)so oversized input will produce a DB error, but the error is mapped to a generic 500.allowed_formatscorrectly rejects whitespace and non-lowercase.organization_modeuses a closed enum map — correct.path(AddPath) has no validation at all. See finding #1._methodoverride pattern makes this more important — any origin can POST with_method=DELETE. Document for bookshelf-4op.library_idandpath_id(integers). No PII, no tokens, no full request bodies.writeErrorinerror_mapper.goreturns onlyhttp.StatusText(status)— no internal paths, DB error text, or stack traces reach the client.ListLibrarieshasLIMIT 200(hard-codeddefaultListLimit).ListLibraryPathsinsideGethas no LIMIT —SELECT id, path, library_id FROM library_path WHERE library_id = ?returns all paths for a library unconditionally. In practice libraries have few paths but it violates the project's "no unbounded queries" rule. See finding #3.http.MaxBytesReaderwrapping on any handler.ReadTimeout: 5son the server provides an indirect cap, but a slow client sending a multi-megabyte JSON body will hold the goroutine for the full 5 seconds before a timeout fires. See finding #4.Findings
WARN
internal/library/service.go:AddPath(line 169) —pathis stored with no validation. No length check, no rejection of.., no normalization. When AXT-2 lands and the scanner walks these paths, a crafted value like../../../../etcor an overly long string could cause filesystem access outside the intended root or a DB truncation error surfaced as an opaque 500.WARN
templates/pages/libraries_index.html:30,templates/pages/libraries_show.html:8,39—data-confirm-message="Delete library {{.Name}}?"anddata-confirm-message="Remove path {{.Path}}?". Go'shtml/templatecorrectly HTML-escapes"inside attribute values (it becomes") so attribute injection is blocked. The JS then readsthis.element.dataset.confirmMessagewhich is the decoded value, so the confirm dialog will show the raw library name. This is fine for display. However, if a library name contains a single quote (e.g.O'Reilly) the dialog shows it correctly becausewindow.confirm()takes a string, not HTML. No actual XSS here — downgrading this to a note rather than a blocker.WARN
internal/db/queries/library.sql:25-27—ListLibraryPathshas noLIMIT. Query:SELECT id, path, library_id FROM library_path WHERE library_id = ?. Violates the project rule "no unbounded queries over user-growable tables."WARN
internal/library/handler.go/internal/app/app.go— Nohttp.MaxBytesReaderon request bodies for create/update/addPath handlers. A 5-secondReadTimeoutprovides a loose safety net but does not bound body size directly.NOTE
internal/library/handler.go:deletePathHandler(lines 288-319) —libraryIDis parsed from the URL but theDeletePathservice call uses onlypathID. There is no check that the path actually belongs to the given library. Today this just means a client can delete any path by its numeric ID regardless of the{id}in the URL. With no auth this is not exploitable beyond a UI inconsistency, but it will matter once auth scopes libraries to users (TODO-AUTH).NOTE
internal/library/service.go:197-214(validate) —namehas nolen(name) > 255guard. DB column isvarchar(255). The error path on overlong insert is an unhandled driver error → generic 500 with no user-friendly message.Verdict
⚠️ Notes (won't block merge) — no blockers found, but three items should become follow-up beads before AXT-2 (scanner) lands:
len(path) > 4096rejection, block empty string, block paths containing\x00. Full canonicalization can wait until scan bead but the null-byte guard should land with this PR or immediately after.len(strings.TrimSpace(name)) > 255→ ErrValidation to produce a 400 instead of a 500.LIMIT 1000(or a configurable cap) to the SQL query.Items 2–4 are safe to ship as-is. Item 1 (path null-byte) is low risk now but should be a fast follow-up bead before the scanner lands.
Security Review (automated)
Reviewed commit
779ee38— Library + LibraryPath CRUD API + UI.Threat assessment
?placeholders. No hand-written string-interpolated SQL found anywhere in the diff.library_path.pathis stored verbatim with zero validation — no length cap, no canonicalization, no rejection of..sequences or absolute paths that escape a configured root. The scanner (AXT-2) hasn't landed yet so there's no exploit path today, but the input gate is open. See finding #1 below.template.HTML/template.JSraw casts —html/templateauto-escaping is intact for all{{.}}expressions. However,data-confirm-message="Delete library {{.Name}}?"puts user-controlled text into an HTML attribute without the"inside the value being escapable by Go's template engine in that context. See finding #2.TODO-AUTHcallsites are absent — recommend adding a comment atRegisterRoutesso the auth bead knows where to insert middleware.namehas no length cap in Go-sidevalidate(). DB column isvarchar(255)so oversized input will produce a DB error, but the error is mapped to a generic 500.allowed_formatscorrectly rejects whitespace and non-lowercase.organization_modeuses a closed enum map — correct.path(AddPath) has no validation at all. See finding #1._methodoverride pattern makes this more important — any origin can POST with_method=DELETE. Document for bookshelf-4op.library_idandpath_id(integers). No PII, no tokens, no full request bodies.writeErrorinerror_mapper.goreturns onlyhttp.StatusText(status)— no internal paths, DB error text, or stack traces reach the client.ListLibrarieshasLIMIT 200(hard-codeddefaultListLimit).ListLibraryPathsinsideGethas no LIMIT —SELECT id, path, library_id FROM library_path WHERE library_id = ?returns all paths for a library unconditionally. In practice libraries have few paths but it violates the project's "no unbounded queries" rule. See finding #3.http.MaxBytesReaderwrapping on any handler.ReadTimeout: 5son the server provides an indirect cap, but a slow client sending a multi-megabyte JSON body will hold the goroutine for the full 5 seconds before a timeout fires. See finding #4.Findings
WARN
internal/library/service.go:AddPath(line 169) —pathis stored with no validation. No length check, no rejection of.., no normalization. When AXT-2 lands and the scanner walks these paths, a crafted value like../../../../etcor an overly long string could cause filesystem access outside the intended root or a DB truncation error surfaced as an opaque 500.WARN
templates/pages/libraries_index.html:30,templates/pages/libraries_show.html:8,39—data-confirm-message="Delete library {{.Name}}?"anddata-confirm-message="Remove path {{.Path}}?". Go'shtml/templatecorrectly HTML-escapes"inside attribute values (it becomes") so attribute injection is blocked. The JS then readsthis.element.dataset.confirmMessagewhich is the decoded value, so the confirm dialog will show the raw library name. This is fine for display. However, if a library name contains a single quote (e.g.O'Reilly) the dialog shows it correctly becausewindow.confirm()takes a string, not HTML. No actual XSS here — downgrading this to a note rather than a blocker.WARN
internal/db/queries/library.sql:25-27—ListLibraryPathshas noLIMIT. Query:SELECT id, path, library_id FROM library_path WHERE library_id = ?. Violates the project rule "no unbounded queries over user-growable tables."WARN
internal/library/handler.go/internal/app/app.go— Nohttp.MaxBytesReaderon request bodies for create/update/addPath handlers. A 5-secondReadTimeoutprovides a loose safety net but does not bound body size directly.NOTE
internal/library/handler.go:deletePathHandler(lines 288-319) —libraryIDis parsed from the URL but theDeletePathservice call uses onlypathID. There is no check that the path actually belongs to the given library. Today this just means a client can delete any path by its numeric ID regardless of the{id}in the URL. With no auth this is not exploitable beyond a UI inconsistency, but it will matter once auth scopes libraries to users (TODO-AUTH).NOTE
internal/library/service.go:197-214(validate) —namehas nolen(name) > 255guard. DB column isvarchar(255). The error path on overlong insert is an unhandled driver error → generic 500 with no user-friendly message.Verdict
⚠️ Notes (won't block merge) — no blockers found, but three items should become follow-up beads before AXT-2 (scanner) lands:
len(path) > 4096rejection, block empty string, block paths containing\x00. Full canonicalization can wait until scan bead but the null-byte guard should land with this PR or immediately after.len(strings.TrimSpace(name)) > 255→ ErrValidation to produce a 400 instead of a 500.LIMIT 1000(or a configurable cap) to the SQL query.Items 2–4 are safe to ship as-is. Item 1 (path null-byte) is low risk now but should be a fast follow-up bead before the scanner lands.
Code Review (automated)
Demo verification
make test— passes (verified viago test -race ./internal/...)make coverage— 100% oninternal/library/confirmed (go test -race -coverprofile=... ./internal/library/...→total: 100.0%)make e2e— not run (Docker required; CI is green per implementer)docker compose up— not run locallySpec compliance
All deliverables from the bead description are present:
internal/db/queries/library.sql— all 8 queries (ListLibraries, GetLibrary, CreateLibrary, UpdateLibrary, DeleteLibrary, ListLibraryPaths, AddLibraryPath, DeleteLibraryPath)internal/db/sqlc/library.sql.go— regeneratedinternal/libraryservice package — functional-args style, no Queries structinternal/libraryhandler package — all routes registered, content-negotiated, WantsJSON branchestemplates/pages/libraries_index.html,libraries_show.html,libraries_form.htmllibraries_form_controller.js,confirm_delete_controller.jsmain.go/internal/appwirede2e/api/libraries_test.go— all endpoint coverageinternal/library/...Missing/concern on spec:
PathCountin the list view (libraries_index.html) always renders as0becauseListLibrariesdoes not join paths androwToLibrary()(used byList()) never setsPathCount. The template atlibraries_index.htmlshows a "Paths" column that will always display 0. Not a blocker for MVP but misleading.Code quality findings
Critical:
templates/pages/libraries_index.html:37,templates/pages/libraries_show.html:6,templates/pages/libraries_form.html:18— HTML forms use<input type="hidden" name="_method" value="DELETE">andvalue="PUT"for browser-initiated delete/update, but there is no method-override middleware ininternal/app/app.goor anywhere ininternal/middleware/. The Go 1.22 mux patternDELETE /libraries/{id}will never match aPOSTwith_method=DELETE. Browser delete and edit-save will both silently fail (likely route to wrong handler or 405). The JSON API path is unaffected. This is a bug that makes the HTML UI non-functional.Important:
internal/library/handler.go:108,211,239,275,310— AllLogger.Infocalls omittrace_id. The logging standard requires "Always trace_id to correlate requests across services." Themiddleware.TraceIDFromContext(r.Context())helper is already used ininternal/app/app.go:155but not in any library handler log line. Example fix:d.Logger.Info("library created", "library_id", id, "trace_id", middleware.TraceIDFromContext(r.Context())).Minor:
internal/library/service.go:83—PathCountis only set inGet()but not inrowToLibrary(). The list view therefore always shows 0 paths. A quick N+1-free fix would be a separateSELECT library_id, COUNT(*) FROM library_path GROUP BY library_idbatched query, but this is an MVP scope call.e2e/api/libraries_test.go:222—Expect(lib["paths"]).To(BeNil())— after deleting the only path, the JSON response forGetreturns an empty slice[], which marshals tonulldue toomitemptyon a nil slice vs empty slice. Worth verifying the actual behavior is consistent.internal/library/handler.go:838— BothPUTandPATCHare wired to the sameupdateHandlerbut the_methodoverride only sendsPUT, soPATCHis dead code unless called via the API directly. Not a bug but YAGNI.Tests
Depth is good. The service tests exercise all error paths including
LastInsertIdfailures. Handler tests cover both HTML and JSON branches, validation errors, and all sentinel error types. The 848-line handler test covers every registered route including invalid-ID and error-propagation cases. Coverage is genuinely 100%, not just line-coverage theatre.One gap: no test verifies that
PathCountis 0 in the list response (because it's not yet wired). That's acceptable for MVP but worth noting.Verdict
⚠️ Nits, may merge — except the
_methodoverride bug is significant: the HTML UI's delete and edit buttons are broken. If this PR is merged as-is, the browser UI will not work for delete/update actions.If the HTML UI being broken is acceptable for this MVP bead (JSON API works), it can merge with a follow-up bead to add method-override middleware. Otherwise the fix is one middleware function + one line in
app.go.Must-fix before merge if browser UI is in scope:
_methodfrom POST form values and rewritesr.Methodbefore routing (standard Go pattern: ~10 lines). Wire it ininternal/app/app.gobefore the mux.Should-fix (non-blocking):
trace_idto allLogger.Infocalls ininternal/library/handler.go.Review-fix commit
Addressed all findings from the code review (comment #1350) and security review (comment #1345):
internal/middleware/method_override.go) — POST +_method=DELETE|PUT|PATCHnow correctly rewrites to the target verb before routing. Browser delete/edit flows are functional. 100% test coverage including multipart/form-data, invalid override values, and non-POST passthrough cases.internal/library/handler.gonow includetrace_idfrom context per logging-standard.internal/middleware/max_bytes.go) — 1 MB body cap on all non-GET/HEAD requests; wired inapp.go.validate()now returnsErrValidation(400) onlen(name) > 255before hitting the DB.validatePath()in service rejects: empty/whitespace, null bytes, len > 4096, relative paths,..traversal segments.deletePathHandler,MethodOverridemiddleware, andRegisterRoutes.e2e/browser/libraries_test.goexercises the full HTML delete flow (navigate → confirm dialog bypassed → delete submitted → row gone). This test would have caught the original method-override bug.CI: success
Mergeable: True
Security Re-review (round 2)
Fix verification
internal/db/queries/library.sqlline 28:LIMIT ?parameterised and called withmaxLibraryPaths = 500(service.go:48, 73). SQL forListLibrariesalso usesLIMIT ?, called withdefaultListLimit = 200(handler.go:17, 57). Both hard-coded at the call site — no user-controlled value reaches the DB. ✅internal/middleware/max_bytes.gowraps all non-GET/HEAD requests at1 << 20(1 MB) viahttp.MaxBytesReader. Applied globally inapp.goat the outer middleware chain level (line 164), so every handler is covered. ✅service.go:221:len(name) > maxNameLen(255) →ErrValidationbefore any DB call. ✅validatePath()at service.go:242–263 covers all five checks:strings.TrimSpace(path) == ""→ ErrValidation ✅strings.ContainsRune(path, 0)→ ErrValidation ✅len(path) > maxPathLen(4096) → ErrValidation ✅!filepath.IsAbs(path)→ ErrValidation ✅..segments): segments loop rejects any component== ".."→ ErrValidation ✅handler.go:36:RegisterRoutes— TODO(auth) note about auth middleware insertionhandler.go:307:deletePathHandler— TODO(auth) note about ownership verificationmethod_override.go:32: TODO(auth) CSRF ordering note ✅Fresh-pass findings
internal/middleware/method_override.goSolid design. The
validOverridesclosed set correctly whitelists only PUT/PATCH/DELETE, so injection of arbitrary methods (CONNECT, TRACE, etc.) is impossible. The middleware only fires whenr.Method == POST, preventing non-POST requests from being silently mutated. JSON bodies are not parsed — onlyx-www-form-urlencodedandmultipart/form-datatriggerParseForm/ParseMultipartForm, so a JSON-encoded_methodfield in a JSON POST body is harmless (verified by test at method_override_test.go:82–87).One minor note: the
multipart/form-databranch callsr.ParseMultipartForm(maxFormSize)with its own 1 MB cap (1 << 20, defined locally in method_override.go:17). At the outer middleware chain levelMaxBytesalready wraps the body beforeMethodOverrideruns (app.go:163–164, outer → inner order: MaxBytes wraps, then MethodOverride wraps), so an oversized multipart body is rejected byMaxBytesReaderbeforeParseMultipartFormeven attempts to buffer it into memory. The double-cap is belt-and-suspenders and is not a bypass vector.internal/middleware/max_bytes.goClean. The middleware delegating 413 status relies on downstream code reading
r.Bodyand surfacing the error. In practice the echo handler in the test, and the real handlers all read viajson.NewDecoder(r.Body).Decode(...)orr.FormValue(...)which go throughParseForm. WhenMaxBytesReaderlimit fires,Readreturns*http.MaxBytesError;Decodesurfaces it as a non-nil error that propagates up throughErrorMapperto a 413 response. No bypass identified.internal/library/service.govalidatePath()The
..-rejection algorithm splits onfilepath.Separatorand checks each segment for literal"..". This correctly catches:/../etc/passwd→ absolute + has..segment → rejected ✅/foo/../bar→ has..segment → rejected ✅/foo/..bar/baz→ segment is"..bar", not".."→ passes (correct —..baris not a traversal component; only..alone is) ✅foo/bar→ not absolute → rejected before traversal check ✅No symlink resolution is performed, which is consistent with the comment "does NOT stat the filesystem — the scanner (AXT-2) owns that concern." This is the right split of responsibility: the scanner can canonicalise / follow symlinks when it actually touches the filesystem. The current lexical validation at the API boundary is appropriate.
One observation: the order of checks puts the
filepath.IsAbsguard before the traversal loop, which means a relative path likefoo/../barfails at the absolute check first — so the traversal check never runs for relative paths. This is fine because relative paths are already rejected, but it means the traversal check is only meaningful for absolute paths starting with/. That is correct behaviour, not a bug.Verdict
✅ No security concerns — all round-1 items are addressed correctly and the new middleware has no exploitable weaknesses. Ready to merge.
Code Re-review (round 2)
Fix verification
Method-override middleware exists + wired + tested
internal/middleware/method_override.go— clean whitelist-based implementation (PUT, PATCH, DELETE only), guards on Content-Type (url-encoded + multipart), no-op passthrough for all other methods.internal/app/app.go:163aschain = middleware.MethodOverride()(chain), placed beforeTraceIDand the mux — correct order per the comment at line 161.internal/middleware/method_override_test.go— 11 cases: POST→DELETE (url-encoded), POST→PUT, POST→PATCH, POST→DELETE (multipart), GET passthrough, DELETE passthrough, application/json ignored, missing_methodfield, invalid override value (CONNECT), lowercase override value (delete), empty value, absent Content-Type. All required edge cases are covered.MethodOverridehits 100% in the per-package run; the overall middleware package is 99% only becauseNewContextWithTraceID(a test-injection helper) is not called within the middleware package's own tests — it is exercised frominternal/tmpltests and reaches 100% in the cross-package coverage run.trace_id on all 5 Logger.Info calls
handler.go:109— library createdhandler.go:212— library updatedhandler.go:240— library deletedhandler.go:276— library path addedhandler.go:312— library path deletedAll five now include
"trace_id", middleware.TraceIDFromContext(r.Context()).Browser e2e exercises delete flow
e2e/browser/libraries_test.goseeds a library via JSON API, navigates to/libraries, verifies the row is present, stubswindow.confirmto auto-accept, clicksform[action="/libraries/{id}"] button[type="submit"], waits for page load, then asserts viaEventuallythat the row selector returns 0 elements. This directly exercises the_method=DELETEform path and would have caught the original missing-middleware bug.Test results
All non-DB-dependent packages pass with race detector:
internal/apptimed out locally due to Docker/MySQL not running — this is the expected behaviour when testcontainers cannot start a container; CI runs against a live MySQL service container. Not a regression.Fresh-pass nits
internal/middleware/method_override.go:17—maxFormSizeis declared as aconstinside the middleware file but is also the effective cap used whenMethodOverrideparses multipart bodies.internal/middleware/max_bytes.go:8declaresmaxRequestBodySizeas a separate constant with the same 1 MB value. These two constants are not shared; if someone bumps one they will not bump the other. Harmless now, but worth unifying in a follow-up.page.MustWaitLoad()after the delete click.rod'sMustWaitLoadwaits for theloadevent but not for network quiescence. On a slow CI runner this could be flaky;MustWaitStable()(as used on the initial navigation) would be more robust. Not a blocker.Verdict
✅ Ready to merge — all three must-fixes from round 1 are addressed, tests pass, coverage is 100% on
internal/libraryand 100% oninternal/middleware/method_override.go. The two fresh nits are minor and do not block merge.@ -95,0 +99,4 @@Logger: logger,Renderer: renderer,AssetVersion: assetVersion,ListFn: func(ctx context.Context, limit int32) ([]library.Library, error) {Can't all of these be something like:
etc?
@ -234,1 +236,4 @@})Describe("GET /libraries (JSON)", func() {It("returns 200 with an empty JSON array", func() {Itblocks should have a single assertion in it. The test setup should be inBeforeEachand invocation of the thing under test should happen inJustBeforeEach. Make sure this happens for all tests in this PR.@ -0,0 +22,4 @@Renderer *tmpl.RendererAssetVersion stringListFn func(context.Context, int32) ([]Library, error)Let's omit the
Fnsuffix on these as a project convention.@ -0,0 +19,4 @@}// List returns up to limit libraries ordered by sort/name.func List(As a project convention, our functions should have dependencies in the top level function and it should return another function with the actual parameters, like:
Security Review (round 3)
Refactor security check
Round-1 fix regression check
Fresh findings
path_count SQL (
internal/db/sqlc/library_extra.go): TheCountPathsForLibrariesfunction builds anIN (?, ?, ...)clause by constructing one"?"placeholder per ID in a loop, then passesargs []anytoQueryContext. All values areint64— no user-supplied strings, no string interpolation of user data. The query has noLIMITbut this is appropriate: it is aSELECT library_id, COUNT(*) … GROUP BY library_idaggregation over a bounded set of IDs from the priorListLibrariesquery (itselfLIMIT ?). At mostdefaultListLimit(200) IDs are passed in; the result set has at most one row per library. No injection risk; no unbounded scan.Curried service functions (
internal/library/service.go): Each outer function (e.g.List,Get,Create,AddPath, …) captures only itsfuncarguments — the sqlc method references (q.ListLibraries,q.CountPathsForLibraries, etc.) which are bound once at app startup viaapp.go:102–109. The returned inner function receivesctx context.Contextand request-scoped scalar params as regular arguments. No per-request state (auth tokens, request headers, trace IDs, body content) is ever captured in the outer closure; all of that flows throughctxand explicit parameters on the inner call. No leakage vector.E2E table truncation (
e2e/testutil/server.go): Truncation only runs whenBOOKSHELF_DSNis set (shared CI MySQL service container). In that mode, eachNewTestEnv()call truncates all user-data tables exceptschema_migrationsbefore starting the test. This improves isolation — each test starts from a blank slate. The truncation cannot mask security bugs because auth is not yet implemented (all routes are unauthenticated); once auth lands, the truncation will correctly wipe any lingering session/user state between tests. TheSET FOREIGN_KEY_CHECKS=0/1bracket around truncation is correct practice and does not create a window for data leakage between test cases. The only minor note: table names come frominformation_schema.tablesand are inserted intoTRUNCATE TABLE \%s`with backtick quoting — the schema isDATABASE()` scoped, so no user input reaches the table-name string. No injection risk.TODO-AUTH comments: Two are present and unchanged —
handler.go:37(route registration comment) andhandler.go:307(deletePathHandler body, noting path-ownership check deferred to auth landing). Both reference beadbookshelf-4op. ThedeletePathHandlerstill accepts any validpathIdwithout verifying it belongs to the givenlibraryId. This is the known, tracked, intentional gap — not a regression.Verdict
✅ Clean — all round-1 fixes survive the refactor, the new SQL and curried-closure pattern introduce no new vulnerabilities, and E2E isolation is correct. The only open item remains the TODO-AUTH path-ownership check, already tracked in bead bookshelf-4op.
d5bfdc2bfdc928e3c8c4