feat(hardcoversync): Sync reading activity to Hardcover.app (bookshelf-oi1l2) #1070
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-oi1l2"
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/hardcoversync) with token storage, sync service, and HTTP handlersuser_settings(keyhardcover.sync.token) — fully per-user scopedGET /account/hardcover: shows token status, save/clear form, manual sync trigger with resultsSyncUserBooksmaps pergamumread_status→ Hardcoverstatus_id(WANT_TO_READ→1, READING/RE_READING→2, READ→3; UNREAD skipped) andpersonal_rating0–10 → Hardcover 0.5–5.0 floaton_conflictconstraintuser_books_user_id_book_id_key)ListUserBooksForHardcoverSyncjoinsuser_book_progress+book_metadatafor books with non-nullhardcover_book_idandread_statushttptest.ServerTest plan
make testpasses includinginternal/hardcoversync/...make lintreports 0 issues forinternal/hardcoversync/...make buildcleanNotes
ErrAuthFailureis returned on 401/403; non-integerhardcover_book_idvalues are skipped with a Warn logCloses bead bookshelf-oi1l2 on merge.
Add a per-user Hardcover.app sync feature: - `internal/hardcoversync`: new domain package with service, handlers, routes, and wiring - Token stored in `user_settings` with key `hardcover.sync.token` - `GetToken` / `SetToken` via curried functional pattern - `SyncUserBooks` lists user's books with hardcover_book_id + read_status via sqlc query, maps read_status → Hardcover status_id (1/2/3), converts personal_rating (0-10) → Hardcover rating (0.5-5.0) - `upsertUserBook` GraphQL mutation with on_conflict upsert for idempotency - Full error handling: 401/403 → ErrAuthFailure; non-integer book IDs skipped - 100% test coverage; httptest.Server substituted for Hardcover HTTP boundary - `internal/db/queries/hardcover_sync.sql`: sqlc query `ListUserBooksForHardcoverSync` joining `user_book_progress` + `book_metadata` filtered to rows with non-null read_status and hardcover_book_id - `internal/db/sqlc/hardcover_sync.sql.go`: generated by sqlc - `templates/pages/hardcover_settings.html`: settings page at `GET /account/hardcover` — shows token status, token save/clear form, manual sync trigger, and sync results - `templates/pages/account.html`: add "Connected Services" section linking to the Hardcover settings page - `Makefile`: add `./internal/hardcoversync/...` to UNIT_PKGS - `internal/app/app.go`: wire `hardcoversync.Wire` into module list Note: live verification requires a real Hardcover user API token; the unit tests cover all code paths at the HTTP boundary via httptest.Server. Follow-up bead for automatic on-change sync triggers will be filed separately. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>Security Review — PR #1070 (bookshelf-oi1l2) Hardcover.app per-user sync
Independent read-only security review of the diff (
git diff origin/main...origin/bd-bookshelf-oi1l2). Scope: secret storage + outbound external API.Verified clean
handler.go:132logs onlyuser_id,action(saved/cleared),trace_id— never the token value.service.gologsuser_id,hardcover_book_id,read_status,status_id,duration_ms— no token. The token travels only in theAuthorization: Bearerheader (client.go:106), which is never logged. Error wrapping never includes the token (client.gowraps status code + response body + GraphQL message, none of which carry the token)./account/hardcover(handler.go:settingsPageHandler) computes onlyTokenConfigured bool; the token value is never placed in template data.hardcover_settings.htmlrenders a masked placeholder +type="password"input with novalue. No GET returns the stored token.user.IDfromd.ExtractUser(r)(session) and rejectsuser.ID == 0.GetToken/SetToken/SyncUserBooksare all keyed by the sessionuserID; no request body/query param supplies an id.ListUserBooksForHardcoverSyncisWHERE ubp.user_id = ?anduser_settingsreads/writes carryUserID. No cross-user path — user A cannot use user B's token or read user B's books.apiURLis the fixed constantdefaultAPIURL = https://api.hardcover.app/v1/graphql(client.go:12);Wirepasses""→ production URL. Not user-controllable. Go's http.Client strips theAuthorizationheader on cross-host redirects, so a malicious redirect cannot exfiltrate the bearer token.variablesmap (marshalUpsertRequest), not concatenated into the query string. The SQL is the parameterized sqlc query.AuthMiddleware → CSRF(app.go:798)./account/hardcover/*is NOT inisCSRFExempt, so both POSTs require the_csrftoken; the templates embed{{.CSRFToken}}. Handlers additionally enforceuser.ID != 0as defense-in-depth.Findings
[MAJOR] internal/db/queries/hardcover_sync.sql:6 — Unbounded query + synchronous unbounded fan-out in a request handler (resource exhaustion)
ListUserBooksForHardcoverSynchas noLIMIT(violates the Scale hard rule: every SELECT over a user-growable table must be bounded). Worse,syncHandlercallsSyncUserBooksinline (handler.go), which loops over every returned row issuing a serial external HTTP call (up to 15s each) to Hardcover — all inside the HTTP request goroutine. A user with a large library (target scale: hundreds of thousands of books) turns a single POST/account/hardcover/syncinto a very long-running request holding a goroutine + outbound connections; repeated triggers are an availability/DoS vector. Fix: move the sync to a background worker (bounded, single-digit fan-out per the project rule) and/or bound/paginate the query with aLIMIT, returning immediately from the handler.[MINOR] templates/pages/hardcover_settings.html:31 — "stored securely" overstates at-rest protection
Copy says the token is "stored securely," but it is persisted in plaintext in
user_settings(no encryption at rest). Either soften the wording or encrypt the value at rest. (Consistent with how the app stores other provider secrets, hence MINOR — but the copy shouldn't overstate.)REVIEW VERDICT: 0 blocker, 1 major, 1 minor
UI Screenshots — Hardcover Settings (
GET /account/hardcover)Empty state (no token configured):

Token configured state:

Captured with go-rod against a minimal
httptest.Serverwith the real templates.Hardcover Settings — styled screenshots (oi1l2)
Without token configured:

With token configured:

The Sync Now button now queues a background go-workflows job and returns immediately.
The token copy says "kept private and never shown again after saving."
zombor referenced this pull request2026-07-09 18:07:09 +00:00
- Add hardcoverSyncPushWorkflowFn var (overridable for tests) to allow injecting TimerDelayedHardcoverSyncPushWorkflow for the cancel path - Add TimerDelayedHardcoverSyncPushWorkflow and WithHardcoverSyncPushWorkflowFn to export_test.go following the established timer-delayed pattern - Add fan-out cancel test: uses 5s cancel + 10s push timer to exercise the `if fanErr := boundedFanOut(...); fanErr != nil { return fanErr }` path at hardcover_sync_workflow.go:182-184 (previously count=0) - Cover StartHardcoverSyncWorkflow already-exists path and generic error path using NewTestEngineWithHardcoverSync stubs + ErrHardcoverSyncInstanceAlreadyExists - Cover ListBooksPageForHardcoverSync error path - All wfengine statement blocks now have count>0 with and without -tags integration Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>UI Review — Hardcover Sync settings page (#1070 / bookshelf-oi1l2)
Screenshot reviewed:
/account/hardcoverwith token configured, both cards rendered.What I see in the rendered PNG
The page is visually on-brand: dark theme, two card sections with bordered
.account-sectionstyling, a breadcrumb trail, the "Hardcover API Token" card with a status line + masked password input + "Save token" button, and a "Manual Sync" card with a "Sync now" button (enabled). Typography, spacing, and button color all match sibling account pages.Findings
[MINOR] templates/pages/hardcover_settings.html:3 —
account-breadcrumb,account-breadcrumb-link,account-breadcrumb-sepclasses have no CSS rulesThe breadcrumb nav at the top of the page uses three new class names (
account-breadcrumb,account-breadcrumb-link,account-breadcrumb-sep) that appear nowhere instatic/css/main.css(confirmed:grepon both main and branch CSS returns nothing). The elements render via browser/element defaults — the<a>gets the app link color, the separator is plain text — which happens to look fine now. But these are unfulfilled CSS promises: a future dev who wants to tighten breadcrumb spacing or mute the separator has no hook, and any CSS added for these names later could silently change a page that "worked" without it. Fix: either add CSS rules for these classes, or use a plain<nav aria-label="breadcrumb">without class names and style via element selectors, matching whatever breadcrumb pattern the codebase eventually canonicalises.[MINOR] templates/pages/hardcover_settings.html:21 —
account-section-descclass has no CSS ruleThe
<p class="account-section-desc">elements (description paragraphs inside each card) rely on a class that has no matching rule instatic/css/main.css. They render as unstyled paragraphs inheriting body text, which is visually fine today. The canonicalaccount.htmlsections go directly fromh2toformwith no description paragraphs — so this is new territory — but if description text is going to be a recurring pattern across account sub-pages it warrants a defined rule. Fix: add.account-section-desc { font-size: 0.9375rem; color: var(--fg-muted); margin-bottom: var(--space-4); }(or equivalent) to the account-page CSS block.What passes
.account-page,.account-section,.account-section-title,.account-banner--{success,error},.form-group,.label-optional,.form-actions,.form-hint,.btn— all reuse the existing design-system classes correctly.style=— clean (no CSP risk).class="btn", which is the canonical primary button. They render as the correct purple/indigo filled style.<label>+.label-optionalhint-in-label + password<input>is identical to theaccount.htmlprofile form pattern. Canonical.form-hintfor the "Save your API token first" disabled-state hint is the right class, properly defined in CSS./accountpage.REVIEW VERDICT: 0 blocker, 0 major, 2 minor
CODE REVIEW: bookshelf-oi1l2 (PR #1070)
PHASE 0: DEMO Verification
No DEMO block provided in dispatch. Proceeding with structural code review.
Phase 1 & 2: Spec Compliance & Code Quality
1. Sync moved to background + bounded ✅
Handler (
internal/hardcoversync/handler.go:150): POST enqueues workflow viad.StartSync(r.Context(), user.ID)and returns immediately—no inline blocking HTTP calls.Workflow (
internal/wfengine/hardcover_sync_workflow.go): Paginates usingListBooksPageForHardcoverSync(batch size 50, overridable), usesContinueAsNewfor multi-page epochs, fans out viaboundedFanOutwithconcurrency=4(single digit).Pagination query (
internal/db/queries/hardcover_sync.sql:23):ListUserBooksForHardcoverSyncPagehasLIMIT ?andafterBookID > ?cursor, scoped byuser_id.Bounded fan-out (
internal/wfengine/fanout.go:81): Sliding-window with concurrency cap, awaits oldest in-flight, schedules next. Proper cancellation drain.VERDICT: [PASS]
2. Workflow test quality (≥3 ContinueAsNew epochs + SUCCESS assertion) ✅
Multi-epoch test (
internal/wfengine/hardcover_sync_workflow_test.go:289): batch size=1 + 4 books → 5 epochs (4 with books, 1 empty terminal). AssertsExpect(workflowErr).NotTo(HaveOccurred())(SUCCESS, not absent-of-error) and 4 sub-workflows.Per-book push test (
line:216): success path, auth-failure permanent wrapping, transient retryable check, cancellation drain via timer-delayed workflow.VERDICT: [PASS]
3. Real-engine registration test ✅
Test (
internal/wfengine/engine_new_test.go:540): Uses productionNewWithFactoryExtwith in-memory backend, verifies workflow + activities register per-queue, confirms InstanceID returned.VERDICT: [PASS]
4. Architecture boundary (domain ≠ wfengine) [HARD] ✅
Service (
internal/hardcoversync/service.go): NO go-workflows import.ErrAuthFailureis plain sentinel in client.go.Wfengine layer (
internal/wfengine/hardcover_sync_workflow.go:68):PushBookForHardcoverSyncwraps ErrAuthFailure withgowf.NewPermanentError— ONLY layer allowed to call this.VERDICT: [PASS]
5. Permanent vs retryable error classification ✅
Auth failure (ErrAuthFailure, 401/403) →
gowf.NewPermanentError(no retry).Transient (network, 5xx) → retryable with MaxAttempts=2.
VERDICT: [PASS]
6. Copy (hardcover_settings.html) ✅
Text: "Your token is kept private and never shown again after saving." Describes UI behavior (password field), not encryption. Acceptable.
VERDICT: [PASS]
7. Coverage & test hygiene ✅
Black-box tests (
hardcoversync_test,wfengine_testpackages). No coverage exclusions added. Assertions follow one-per-It convention.VERDICT: [PASS]
8. Bounded concurrency (HARD RULE) ✅
hardcoverSyncFanOutConcurrency = 4(single digit). Sliding-window correctly implemented. Test verifies 4 books → 4 sub-workflows (not unbounded).VERDICT: [PASS]
FINAL VERDICT
REVIEW VERDICT: 0 blockers, 0 majors, 0 minors
Fix successfully moves Hardcover sync from inline blocking to background go-workflows with proper pagination, bounded fan-out, architecture boundaries, and error classification.
APPROVED for merge.
Account page — Connected Services list (styled):
Hardcover Sync page — canonical back-link button:
Account page — Connected Services list (styled):
Hardcover Sync page — canonical back-link button:
23e0528ce461708dd30a