Skip to content

Lenny borrow: no way to return a provider loan from Open LibraryΒ #13909

Closed

Description

@openlibrary-bot

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

  1. A Return control for a provider loan, reachable from the book page. Whether it belongs in ReadingOptionsMenu beside Read, as a ReturnForm sibling the way the IA branch does it, or both, is a design call for the implementer β€” the IA branch is the precedent to match.
  2. ReturnForm.html grows 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.
  3. On success, call invalidate_provider_loans(username) (openlibrary/core/provider_loans.py). Skipping this is not cosmetic β€” a stale lending.provider_loans memcache 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

  1. openlibrary-bot commented on Oct 13, 2026

    @openlibrary-bot
    CollaboratorAuthor

    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.html has 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's data-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_loan is non-None only when render_read_button runs with check_loan_status=True, which LoanStatus.html sets only on an edition book page (check_loan_status and is_edition). So a Return control inside read_button.html's provider_loan branch cannot appear on a carousel without a separate bug β€” the boundary is inherited, not re-implemented.

  2. openlibrary-bot commented on Oct 13, 2026

    @openlibrary-bot
    CollaboratorAuthor

    Validation plan

    Approach

    Match the Internet Archive precedent: render a Return control as a ReturnForm sibling below the Read link in book_providers/read_button.html's provider_loan branch (the only place a provider loan's existence is known). Not in ReadingOptionsMenu β€” 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.html takes an explicit action URL instead of hardcoding /borrow/ia/$ocaid.

    Backend β€” openlibrary/plugins/upstream/lenny.py

    • return_loan(issuer, token, edition_id) β€” mirrors borrow(): POST {issuer}/v1/api/oauth2/return, form field edition_id, Bearer token, 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_edition else 404; resolve access_token_for; call return_loan.
      • Success (2xx) β†’ invalidate_provider_loans(username), success flash, redirect.
      • 401/403 β†’ "reconnect the library" flash, no invalidate.
      • other 4xx/5xx / network / no token β†’ generic "could not return, try again" flash, no invalidate (never pretend a return that did not happen; provider_unreachable stays honest).
      • Redirect target = POSTed redirect sanitised to path+query+fragment (open-redirect guard, as handle_borrow_async does), fallback to the edition page.
    • return_path(olid) helper + return_url on the loan dict in loan_from_node (symmetric with read_url; keeps the generic template provider-agnostic). read_url stays the external node URL; return_url is the internal OL route.
    • Import invalidate_provider_loans. No change to SCOPES β€” return reuses borrow, existing tokens work.

    Frontend

    • macros/ReturnForm.html: first param ocaid β†’ action_url; action="$action_url". Body otherwise unchanged (action=return + redirect hidden fields kept; the Lenny route ignores action, honours redirect).
    • Callers updated to pass the URL: 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 the provider_loan branch, 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 (posts edition_id+Bearer to .../oauth2/return; typed/non-JSON failures surface). TestLennyReturnRoute (mirrors TestAuthorizeLeg/TestCallback): invalidate-called-on-success; invalidate-not-called on node-unreachable, on 401/403, and on no-token; signed-out β†’ login redirect; honours/sanitises redirect. TestLoanFromNode: return_url present and is the OL route.
    • read_button render test: Return control present for a provider_loan carrying return_url; absent when the dict lacks it (pre-change cache entry); form action equals the passed URL.
    • test_link_track_attribute_escaping.py + the fixture: still green after the rename; add an assertion that the IA caller still renders action="/borrow/ia/<ocaid>" (locks the single-form contract for both IA and provider).

    Success criteria

    • Automated: new + existing test_lenny.py, test_provider_loans.py, test_loan_actions.py, test_link_track_attribute_escaping.py green in Docker; pre-commit clean.
    • Manual (book page): Return appears only where a live provider loan exists; absent on carousels/lists/search; clicking it ends the loan and the next render shows Borrow (cache invalidation β€” the one the issue flags as likely-wrong); node-unreachable shows an honest error and still offers the loan.

    Out of scope

    Return from /account/loans or My Books; the read/redirect_reader path (cookie-only, open question with Mek); mybooks.py not merging provider loans.

    Risks

    • Blocked on Returning a book requires a cookie, so the borrow scope cannot do itΒ ArchiveLabs/lenny#240 (the node's /oauth2/return does 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 and pr-240, not a silent adaptation.
    • Adding return_url to 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions