Repository navigation
Lenny borrow: no way to return a provider loan from Open LibraryΒ #13909
Description
A patron who borrows through a Lenny node has no way to give the book back from Open Library β not from the Read button's dropdown, and not from the standard Return button that an Internet Archive loan gets. The loan can only be ended on the node itself, which the patron has no reason to visit and (today) often cannot sign in to.
What the code does now
macros/LoanStatus.html branches on lending_state. The Internet Archive branch renders a return control; the provider branch does not:
$if lending_state == 'borrowed': # Internet Archive
$:macros.ReadButton(...)
$if is_book_page:
$:macros.ReturnForm(ocaid, user_loan['expiry'], ...) # <- the standard Return button
$elif lending_state == 'partner': # every non-IA provider, Lenny included
$:book_provider.render_read_button(...) # <- no ReturnForm, at all
Following the partner branch down, templates/book_providers/read_button.html has a provider_loan branch that correctly offers Read once the patron holds a loan, and next to it locate_menu() β macros/ReadingOptionsMenu.html, whose entire contents are Listen and Nearby Libraries. There is no Return row to inherit.
And macros/ReturnForm.html could not be reused as-is even if it were called: its form action is hardcoded to the Internet Archive.
<form action="/borrow/ia/$ocaid" method="post" class="return-form">
So the gap is three-sided: no control in the dropdown, no standard button on the partner branch, and the one return form we have is IA-only.
Scope
- A Return control for a provider loan, reachable from the book page. Whether it belongs in
ReadingOptionsMenubeside Read, as aReturnFormsibling the way the IA branch does it, or both, is a design call for the implementer β the IA branch is the precedent to match. ReturnForm.htmlgrows a provider path rather than a second near-copy. Note that two copies of one form is how a latent bug gets a control: if you do split them, make them disagree visibly.- On success, call
invalidate_provider_loans(username)(openlibrary/core/provider_loans.py). Skipping this is not cosmetic β a stalelending.provider_loansmemcache entry already caused a live misdiagnosis during the borrow work, where the book page kept offering a loan the node no longer held.
Blocked on ArchiveLabs/lenny#240
Lenny cannot currently accept a return from a token holder. return_item (lenny/routes/api.py) is wrapped in @requires_item_auth(), which resolves identity from a session cookie; there is no /oauth2/return. Open Library holds an OAuth token and no cookie, so there is nothing for this PR to call yet.
One thing that makes the dependency cheaper than it looks: #240 reuses the existing borrow scope rather than minting a new one. _bearer checks tok.has_scope("borrow") (lenny/routes/oauth2.py), and every grant issued so far carries loans:read borrow. No patron has to re-consent β existing tokens work the day #240 lands.
The expected contract, from #240: POST /v1/api/oauth2/return, edition_id form field, borrow scope, mirroring the existing /v1/api/oauth2/borrow. Build against that and confirm it before merge rather than after.
Out of scope
Returning from /account/loans or the My Books landing. Those are list views with their own loan plumbing; this is the book page.
Activity
openlibrary-bot commentedon Oct 13, 2026
Discovery trail β re-derived the 5 load-bearing facts against
origin/master; all hold. Two non-obvious findings worth recording before I scope:1.
macros/ReturnForm.htmlhas three callers, not one. A single-form refactor (the approach the issue prefers over a near-copy) ripples to all three:macros/LoanStatus.htmlβ the IA book-page return.templates/account/loan_actions.htmlβ the IA branch of the loans page (out of scope for adding provider return, but a caller of the shared macro, so its call must be updated mechanically).templates/tests/return_form_track_check.htmlβ the analytics-escaping fixture (tests/test_link_track_attribute_escaping.py), which asserts on the submit button'sdata-ol-link-track, not the form action, so the rename is safe for it.2. The book page already keeps the loan lookup off carousels/lists by construction.
provider_loanis non-Noneonly whenrender_read_buttonruns withcheck_loan_status=True, whichLoanStatus.htmlsets only on an edition book page (check_loan_status and is_edition). So a Return control insideread_button.html'sprovider_loanbranch cannot appear on a carousel without a separate bug β the boundary is inherited, not re-implemented.openlibrary-bot commentedon Oct 13, 2026
Validation plan
Approach
Match the Internet Archive precedent: render a Return control as a
ReturnFormsibling below the Read link inbook_providers/read_button.html'sprovider_loanbranch (the only place a provider loan's existence is known). Not inReadingOptionsMenuβ the IA branch puts return as a secondary full-width button, and matching it needs no argument. One form, parametrised (per the issue's preference over a near-copy):ReturnForm.htmltakes an explicit action URL instead of hardcoding/borrow/ia/$ocaid.Backend β
openlibrary/plugins/upstream/lenny.pyreturn_loan(issuer, token, edition_id)β mirrorsborrow():POST {issuer}/v1/api/oauth2/return, form fieldedition_id,Bearertoken,HTTP_TIMEOUT_SECONDS.>=400βLennyReturnError(error, status). (Pinned contract from Returning a book requires a cookie, so the borrow scope cannot do itΒ ArchiveLabs/lenny#240.)lenny_return(delegate.page),POST, path/borrow/lenny/(OL\d+M)/return: require login (else redirect to login βborrow_path);node_for_editionelse 404; resolveaccess_token_for; callreturn_loan.invalidate_provider_loans(username), success flash, redirect.provider_unreachablestays honest).redirectsanitised to path+query+fragment (open-redirect guard, ashandle_borrow_asyncdoes), fallback to the edition page.return_path(olid)helper +return_urlon the loan dict inloan_from_node(symmetric withread_url; keeps the generic template provider-agnostic).read_urlstays the external node URL;return_urlis the internal OL route.invalidate_provider_loans. No change toSCOPESβ return reusesborrow, existing tokens work.Frontend
macros/ReturnForm.html: first paramocaidβaction_url;action="$action_url". Body otherwise unchanged (action=return+redirecthidden fields kept; the Lenny route ignoresaction, honoursredirect).macros/LoanStatus.html('/borrow/ia/%s' % ocaid),templates/account/loan_actions.html(same),templates/tests/return_form_track_check.html.templates/book_providers/read_button.html: in theprovider_loanbranch, after the Read group,$if provider_loan.get('return_url'): $:macros.ReturnForm(provider_loan['return_url'], provider_loan.get('expiry'), analytics_track="LoanStatus|ReturnClick").Tests (red-first β watched failing against unfixed source)
test_lenny.py:TestReturnCall(postsedition_id+Bearer to.../oauth2/return; typed/non-JSON failures surface).TestLennyReturnRoute(mirrorsTestAuthorizeLeg/TestCallback): invalidate-called-on-success; invalidate-not-called on node-unreachable, on 401/403, and on no-token; signed-out β login redirect; honours/sanitisesredirect.TestLoanFromNode:return_urlpresent and is the OL route.provider_loancarryingreturn_url; absent when the dict lacks it (pre-change cache entry); formactionequals the passed URL.test_link_track_attribute_escaping.py+ the fixture: still green after the rename; add an assertion that the IA caller still rendersaction="/borrow/ia/<ocaid>"(locks the single-form contract for both IA and provider).Success criteria
test_lenny.py,test_provider_loans.py,test_loan_actions.py,test_link_track_attribute_escaping.pygreen in Docker; pre-commit clean.Out of scope
Return from
/account/loansor My Books; the read/redirect_readerpath (cookie-only, open question with Mek);mybooks.pynot merging provider loans.Risks
/oauth2/returndoes not exist yet). I build and unit-test the whole OL side against the pinned contract; end-to-end against a live node is unverified and the PR body's RAN/READ split will say so plainly. A contract change is a conversation with the lead andpr-240, not a silent adaptation.return_urlto the cached loan dict means entries cached before deploy lack it for β€PROVIDER_LOANS_TTL(60s); the template uses.get(...)so it degrades to no-Return-button and self-heals on the next background refresh. Noted (the "cache has no notion its shape changed" class).Gate self-assessment: no staff gate. The approach is specified by the issue + brief, the one design latitude (menu vs sibling form) is resolved to the IA precedent, and nothing here is destructive or irreversible today (the live action is gated behind an endpoint that does not yet exist). Proceeding to implementation.
feat(lenny): return a provider loan via the stored OAuth token
feat(lenny): return a provider loan via the stored OAuth token
feat(lenny): return a provider loan via the stored OAuth token
feat(lenny): add the Return control to the provider-loan book page
feat(oauth2): return a book by bearer token (POST /oauth2/return) (#249)
feat(lenny): add the Return control to the provider-loan book page
feat(lenny): add the Return control to the provider-loan book page
feat(lenny): return a Lenny provider loan from Open Library (#13909) (#β¦