feat(tui): pergamum tui skeleton + device-grant login + paginated book list (bookshelf-wtsp.7) #1237
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-wtsp.7"
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
pergamum tui login— performs the RFC 8628 OAuth Device Authorization Grant:calls
POST /oauth/device/code, printsuser_code+verification_urifor the userto open in their browser, polls
POST /oauth/tokenuntil approved, then stores theresulting pergamum access+refresh JWTs in
~/.config/pergamum/tui-tokens.json(0600).pergamum tui books— Bubble Tea paginated book list browser: fetchesGET /bookswithAuthorization: Bearer, cursor-paginated at 50/page,nto loadthe next page,
qto quit; transparently refreshes the access token via the storedrefresh token on 401.
internal/tuipackage:Client(device code request, token poll, refresh, book list)TokenStore(0600 JSON file with atomic write) — 100% black-box test coverage, 36Ginkgo specs using httptest.Server stubs.
tuias a new subcommand on the main binary alongsideserve/scan/worker.Depends on bookshelf-wtsp.1 (Bearer support in AuthMiddleware + device-grant endpoints
on the server — already merged).
Test plan
make test— all packages pass including newinternal/tui(36 specs)make coverage— 100% coverage gate passes;internal/tuiat 100%make lint— zero issuesgo build ./cmd/pergamum/...— compiles cleanpergamum tui login --server http://localhost:8080→ shows user_code/URL, waits for browser approvalpergamum tui books→ Bubble Tea list renders;nloads next page;qexitsCloses bead bookshelf-wtsp.7 on merge.
[MAJOR] internal/tui/apiclient_test.go:350-351 — wall-clock timing in test assertions
BeTemporally assertions using time.Now() are flaky in CI environments. Per review-standard.md, tests must not assert on real elapsed time. Fix: inject a clock function into the TokenResponse creation path so tests can control time, or use a fixed past/future time that does not depend on when the test runs. The BeTemporally checks should verify that ExpiresAt is approximately (now + 3600s) using a reasonable tolerance, not by checking against the actual wall clock.
[MAJOR] internal/tui/tokenstore_test.go:149-170 — wall-clock timing in IsValid() test assertions
Lines 154 and 166 use time.Now().Add(...) to create test tokens, then lines 157/169 call IsValid() which itself calls time.Now(). This creates a race: if the test is slow, time.Now() in IsValid() will return a different value than expected, causing flakes. Fix: refactor to accept an injectable now func in the Tokens type or create a test helper that returns predictable times. Alternatively, use fixed past/future dates that never depend on the actual current time.
[MINOR] internal/tui/apiclient_test.go:143-147, 270-275, 307-310 — multiple Expect calls per It block
The one-Expect-per-It convention requires exactly one Expect call per It block. These It blocks contain 4, 2, and 2 separate Expect calls respectively. Fix: fold multiple assertions into a single Expect using Gomega matchers (e.g., Expect(result, err).To(And(HaveField(...)...)) or split each assertion into its own It block with a descriptive name.
REVIEW VERDICT: 2 major, 1 minor
Security re-review (FINAL, adversarial) — reserve-then-refund OPDS rate limiter
Verdict up front: the reserve-then-refund design is sound, but this implementation of the refund is broken in production and reintroduces the original drain (MAJOR #1). The bug is masked by the frozen-clock unit tests, which is why it survived to iteration 4.
Everything else verified clean:
burstreservations (hence <=burst concurrent hashes) per IP exist at any instant; the burst+1th finds an empty bucket and is rejected pre-hash. Holds under both the broken and the fixed refund.burstfailures -> further attempts rejected pre-bcrypt. Empirically confirmed.burst; the refund closure is bound to the same reservationr/e.limiter(same IP), so it cannot exceed capacity or credit another IP.nowand does not consume; no refill-faster-than-rate race./loginunchanged (still loginLimiter.Allow, consuming). Audits preserved (ActionLoginRateLimited / ActionLoginFailed). No password/secret logged; username sanitized. LoginReserve wired as a non-nil method value in app.New; authenticate nil-guards it.REVIEW VERDICT: 1 blocker, 0 major, 2 minor
Security Review — PR #1237 (TUI OAuth device-grant client, bookshelf-wtsp.7)
Adversarial focus: client-side credential handling (token at rest, user/device code, TLS, refresh, committed secrets, host trust).
[MAJOR] cmd/pergamum/tui_books_model.go:154 — Unbounded refresh/retry loop on a persistent 401
Updatereacts toErrUnauthorizedfromListBooksby callingrefreshAndRetry(). That closure refreshes then re-callsListBooks; if the list request returns 401 again (valid refresh but token audience/permission mismatch, or a server that keeps 401'ing), it returns anotherbooksLoadedMsg{err: ErrUnauthorized}, which re-entersrefreshAndRetry()— with no retry cap and no backoff/sleep. Refresh keeps succeeding, list keeps 401'ing, and the client hot-loops/auth/refresh+/booksas fast as the network allows (self-inflicted DoS / server hammering). The loop only breaks if the refresh itself fails. The spec explicitly wants "a failed refresh clears the stored creds rather than looping." Fix: bound to a single refresh attempt per 401 (track arefreshed/attempt flag in the model); on a second consecutive 401 after a successful refresh, stop and surface "session invalid — run 'pergamum tui login' again" instead of retrying.[MINOR] cmd/pergamum/tui_books_model.go:206 — Failed refresh does not clear stored credentials
When
RefreshTokenfails, the model shows "session expired" but never callsstore.Clear(), leaving the now-invalid refresh token (and stale access token) on disk. Not exploitable (file is 0600, token already rejected), but it deviates from the "failed refresh clears the stored creds" requirement and means the nextbooksrun reloads dead creds and 401s again. Fix:store.Clear()on unrecoverable refresh failure.[MINOR] cmd/pergamum/tui.go:39 — No HTTPS enforcement / cleartext-token warning for non-localhost servers
Default
--serverishttp://localhost:8080(fine, not attacker-controllable). But the URL is used verbatim; a user who points--server http://remote-hostsends the device grant, bearer access token, and refresh token over cleartext HTTP with no warning. TLS is verified when https is used (default transport, noInsecureSkipVerify), so this only covers the plaintext-scheme case. Fix: warn (or refuse) when the scheme ishttpand the host is not loopback.[MINOR] internal/tui/tokenstore.go:71 — Temp-file perms not re-asserted on a pre-existing .tmp
Savewritespath+".tmp"viaos.WriteFile(..., 0600)then renames. On first create this is 0600 (good), butos.WriteFiledoes not chmod an already-existing.tmpleft by a prior crash, so perms could be inherited from a looser pre-existing file. Low risk (per-userUserConfigDir, dir is 0700). Fix:os.OpenFile(tmp, O_CREATE|O_TRUNC|O_WRONLY, 0600)(after removing any stale temp) oros.Chmodpost-write.Positives confirmed (no finding):
MkdirAll(...,0700), path =os.UserConfigDir()/pergamum/tui-tokens.json(correct per-user config dir).device_codeis never printed; onlyuser_code/verification URIs are shown (intended by RFC 8628).InsecureSkipVerify, no custom transport disabling verification; defaulthttp.Clientverifies certs.client_id="pergamum-tui"is a public OAuth client id; noclient_secret, no hard-coded token.REVIEW VERDICT: 0 blocker, 1 major, 3 minor
appModel.Update now caches tea.WindowSizeMsg dimensions and forwards them to the freshly-constructed sub-model on every view transition: - loggedInMsg (Login→Library): after newBooksModel, immediately calls library.Update(WindowSizeMsg{cached w,h}) so the bubbles list gets list.SetSize(...) before Init() fires the first page fetch. Without this the list has 0×0 dimensions (no terminal size msg ever re-fires for the new view) and renders blank rows even though data loads correctly. - sessionExpiredMsg (Library→Login): same forward so the login view is sized if the user is bounced back mid-session. - WindowSizeMsg handling in Update itself now caches m.width/m.height AND delegates to the active sub-model (via the existing delegation switch below the type switch), so both paths — initial sizing and live terminal resizes — keep working correctly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>08bec4c1eb3a8ec705bb