Repository navigation
Lenny OAuth borrow, composed and unblocked: the grant lives in provider_tokens, and schema.sql has the table (#12844) - #13865
Merged
Merged
Conversation
Lenny is a self-hosted lending server, and many organisations run their own node. Each node is its own OAuth authorization server; Open Library is a client of each. #13565 harvests a node's OPDS feed into the acquisitions table, so the borrow links already exist as rows -- 94 of them in production as of 2026-09-09. This is the flow behind them. /borrow/lenny/OL51008637M -> node's /authorize -> /borrow/lenny/callback | POST {node}/v1/api/oauth2/borrow It stops one step short of reading the book, on purpose. A Lenny access token authorizes creating a loan; it does not get the patron through Lenny's read gate, which takes a session cookie only and keys the patron on sha256(lowercased email). So a loan created here is invisible to a patron who later signs in to Lenny directly, and the reader still asks for an OTP. Closing that needs a single-use token-to-session exchange on the Lenny side (ArchiveLabs/lenny#211) and a decision about how the two identities reconcile. A "read now" link today would strand the patron *after* they had borrowed, which is worse than not offering one. Three choices that each look like an omission: No tokens are stored, anywhere. A borrow is one-shot, so this requests no refresh token and uses the access token inside the request that obtained it. Lenny rotates refresh tokens and revokes the whole family on reuse, so two concurrent refreshes destroy a patron's grant and log them out with no traceable error. Storing nothing makes the worst case of a bug in here one failed borrow. Anything wanting loans:read on a bookshelf page needs persistence, and that hazard has to be designed for deliberately -- it is a real cost of that feature, not a detail to add casually. No patron identity is sent. The node authenticates the patron itself. Silent authorization -- OL asserting an already-authenticated patron so the node can skip its own login -- would need a pairwise pseudonymous subject (never an email), an OL signing key, and a decision about whether Open Library should act as an identity provider. The plug-in point is marked in the authorize parameters. prompt=none is unsupported by every node today and unknown parameters are ignored, so nothing is sent. Endpoints come from each node's /.well-known/oauth-authorization-server, so a node moving an endpoint does not need an OL deploy. S256 is required by membership rather than by taking the first offered method, so a node advertising something weaker fails closed. The security of this flow is entirely in what it refuses, so the tests are weighted there and each refusal was verified to be load-bearing by breaking it: not enforcing an iss mismatch fails 2, tolerating a missing iss fails 1, leaving state replayable fails 1, dropping the S256 requirement fails 2, and using the verifier as its own challenge fails 1. The iss check earns its own note. One callback path serves every node, so the URL cannot say which node is answering: the node comes from state and iss must match what that node is registered as. Without it a hostile node a patron also uses can replay a code and have Open Library redeem it elsewhere -- the OAuth mix-up attack. Credentials come from a `lenny_nodes` config key, not from feed_registry.data: that blob is treated as printable config throughout the harvest tooling (it is echoed by `bookworm.cli register --show` and named in its drift warning), so a client secret there would reach stdout, the cron log, and cron mail.
Per Mek's decision: use the node's existing OAuth2 PKCE flow as it is, let the patron sign in at the node with its own OTP, and have Open Library keep the resulting token custodially. That sign-in is not an obstacle to route around -- it is what makes the book readable. An access token authorizes OL's *backend* to call a node's API; it gives the patron's browser no session, so no cookie and no reader. Signing in at the node does, which is why the callback can now offer a working "Read it now" link. The token is Fernet-encrypted into the patron's own cookie with the same key and the same pattern as the S3 keys (encrypt_lenny_token / decrypt_lenny_token alongside encrypt_s3_keys), so there is no table and no server-side row to expire. A patron clearing cookies simply signs in again. One hazard comes with that and is inherent rather than a bug: the cookie is the single copy of a rotating refresh token, and a node revokes the whole family when a rotated one is reused. So a failure clears the cookie and sends the patron back through the flow -- a second sign-in rather than a silent dead end. The silent-authorization plug-in point is gone. It would have saved the patron a sign-in but would not have made the book open, so it was solving the wrong half. 30 tests. A node that issues no refresh token is covered, since it is not obliged to.
…gration
Open Library needs a patron's OAuth grant at each trusted book provider so it
can borrow on their behalf and read their loans back. This adds the storage
layer and the refresh handling; it deliberately does not add the table.
`openlibrary/core/provider_tokens.py` keys one grant on (username,
provider_name) and stores the access token, its expiry, the refresh token and
the granted scope. Tokens are Fernet-encrypted with the same secret and the
same pattern as the S3 keys (`encrypt_token`/`decrypt_token`, added next to
`encrypt_s3_keys` at accounts/model.py:109-127). They are encrypted, not
hashed: Open Library presents them to the node, so a digest would be useless
here. Hashing is right on the node, which verifies, and wrong here, which
sends.
`ProviderToken.get_fresh` is the part that matters. A provider that rotates
refresh tokens revokes the entire token family when a spent one is presented
again, so a concurrent double-refresh logs the patron out with nothing in any
log to explain it. Three things guard against that, all in one method:
1. `SELECT ... FOR UPDATE` on the patron's row, held for the whole exchange,
so a second tab blocks rather than presenting the same refresh token
(`openlibrary/data/db.py:143` is the precedent for the clause);
2. the new pair written in the same transaction that consumed the old one;
3. a failed refresh deletes the grant rather than retrying it -- a timeout
and a rejection are indistinguishable from this side, and a retry is what
turns a lost response into a destroyed grant.
`openlibrary/tests/core/test_provider_tokens.py` splits into two levels on
purpose. 25 tests run anywhere, on SQLite, and cover the protocol. Two more
prove the lock itself under real concurrency and need a real Postgres, because
SQLite has no row locks -- substituting a threading lock there would test the
substitute. They skip unless OL_TEST_POSTGRES is set; the class docstring
gives the two commands.
Every assertion here was watched fail. Eight defects were injected one at a
time and each turned at least one test red, the concurrency test included:
with `FOR UPDATE` removed it reports `['rt-0', 'rt-0']` against an expected
`['rt-0']` -- the same refresh token presented twice, which is the patron
being logged out.
Verified against postgres:18.3 and against SQLite; 27 passed with Postgres
present, 25 passed and 2 skipped without. The 4 failures in
openlibrary/tests/core/test_fulltext.py predate this branch and were confirmed
on a clean tree.
No `provider_tokens` table is created here. Whether this is a table at all --
versus a cookie, versus archive.org acting as a vault -- is still open and is
Mek's call, so the DDL lives only in the test file for now. Nothing else in
this commit depends on the answer.
Refs #13685
Claude-Session: https://claude.ai/code/session_01UViYm1nKqJkiKJWJUr1eas
…13686) One entry in `PROVIDER_ORDER`, which every surface already reads -- book page, search results, carousels -- plus the two things that entry turns out to need. The access kind is read from the harvested row, not synthesized from the identifier. Every other provider here builds its URL out of `identifiers.*` and calls the result open-access, which for Lenny is wrong half the time: 25 of the 50 publications in the live feed are `open-access` and 25 are `borrow`, and `identifiers.lenny` is identical for both. The endpoints do not forgive a guess -- `/v1/api/items/46539165/read` on a borrowable title answers 401 with an OPDS Authentication Document for `Accept: text/html` as well as `*/*`, so a "Read" link there is a dead end in a browser. The access lives only in the Trusted Book Provider `acquisitions` table, which is the store the harvester writes; `Edition.providers` is a different store with the same class name and nothing in the import pipeline assigns it. `book_providers/read_button.html` had branches for `open-access` and `sample` only, so a `borrow` acquisition fell off the end of the if/elif chain and rendered the empty string. Reverting just that file turns `test_a_borrow_acquisition_renders_a_button` red with `assert 'Borrow' in '\n\n\n\n\n\n\n'` -- a provider registered and the patron shown nothing. `handle_borrow_async` forwarded only `open-access` acquisitions to their provider. A `borrow` one fell through to the Internet Archive branch, where `edition.ocaid` on a non-IA edition is infogami's `Nothing` and `__radd__` returns the left operand, so the patron reached a bare `archive.org/stream/`. Lenny is placed below `InternetArchiveProvider` deliberately, and that is the reviewable decision. Of the 50 publications in the feed, checked against production on 2026-09-20: all 25 open-access ones are Standard Ebooks editions that already render a Read button; 21 of the 25 borrowable ones sit on an edition with an `ocaid`; 1 has a hand-added `providers` URL; 3 have nothing else offering them. So this placement changes no button that exists today and newly surfaces Lenny on 3. Above IA, 21 would swap from an IA button to a Lenny one. `get_access` is overridden because the base class answers PUBLIC unconditionally and that value becomes Solr's `ebook_access`, which drives `public_scan_b` and `has_fulltext`. A borrowable title would have been indexed as publicly readable. A per-request memo on `web.ctx` was written and removed. Only web.py clears `web.ctx` per request, so the entry outlived its request under the test runner and editions with no row began answering with another edition's -- eleven tests in `TestNothingToOffer` and `TestEbookAccess` went red on a full-suite run and green in isolation. Batching belongs at the call site, where `WorkSearchScheme.add_non_solr_fields` already holds every row for the page. Verified against live services rather than the wiki, which was dated: 94 works on production carry `id_lenny`; `/books/OL37044817M.json` carries `identifiers.lenny` and no `providers`; `/v1/api/items/46539165/borrow` answers 303 to the node's own "Sign In - Lenny" page for a browser Accept header, so the borrow CTA completes with nothing built on this side. The OAuth handshake that keeps the patron on Open Library is #13688. Ref #13270, #13688 Claude-Session: https://claude.ai/code/session_01UViYm1nKqJkiKJWJUr1eas
`test_the_borrow_button_does_not_link_straight_out_to_the_node` was written without it and stayed green against the unfixed template, because the empty string that branch rendered also contains no node URL. Someone reading that test in a year is in an editor, not in the PR thread, so the reasoning goes on the test. Also name DirectProvider on the template branch: `access` in the edit-book form's `providers` field may be `borrow`, so this branch is not Lenny-only and was not a Lenny-only gap. Claude-Session: https://claude.ai/code/session_01UViYm1nKqJkiKJWJUr1eas
…ge' into 13600-lenny-oauth-token-store
Replaces the `lenny` cookie this branch added with the per-provider token store from #13689 (`ProviderToken.upsert` after the exchange, `ProviderToken.get_fresh` before presenting a token), and fills in the three things the flow was missing to be usable by the loan lookup that comes next. **Why the cookie had to go, and it is not a preference.** Lenny rotates refresh tokens and revokes the entire family when a spent one is presented again, so the storage has to single-flight the refresh. Two tabs send the same cookie, so both hold the same `R0`; a lock can make the loser wait, but when it wakes the only refresh token it has is the `R0` from its own request headers, because `R1` exists solely in the winner's HTTP response to the other tab. There is nowhere server-side to read it back from, so the loser either presents `R0` -- the reuse that destroys the grant -- or abandons a grant that is alive. `get_fresh` re-reads under the lock, which is the step a cookie cannot perform. The cookie also held one node at a time (`get_custodial_token` returned None for any other node), which is the "does not scale to n providers" objection. `encrypt_lenny_token` / `decrypt_lenny_token` go with it; #13689's `encrypt_token` / `decrypt_token` are the same Fernet pattern without the cookie-shaped three-field plaintext. Also here: - **`scope=loans:read borrow`**, not `borrow` alone, so the merged loan lookup (#13687) does not need a second consent. Both are in the node's `scopes_supported`, checked against `lennyforlibraries.org` discovery. - **`login_hint`** with the patron's email at `/authorize`. See the comment on `authorize_url`: it is inert against every node today and the comment says exactly where it is dropped, so nobody reads the parameter as working. - **The grant is stored before the loan is created**, not after. A loan made with a grant Open Library failed to keep is a live credential at a third-party library that Open Library holds no record of and cannot refresh or revoke. - **`node_refresher` carries `REFRESH_TIMEOUT_SECONDS` on both of its calls**, discovery included, because `get_fresh` calls it with `SELECT ... FOR UPDATE` held on the patron's row. - **`access_token_for`** is the seam #13687 hooks. - The pending state keys on the bare username, not `user.key`, which is what the `provider_tokens` row and `anonymize` both use. The module docstring's claim that the flow "deliberately stops one step short of reading the book" was wrong and is corrected in place. The node's `/authorize` refuses to issue a code without a node session (`lenny/routes/oauth2.py:241`), so the patron's browser already holds the session the reader wants by the time Open Library holds a token. The `read_url` / `render_borrowed` code two hundred lines below the claim already assumed this. 32 new tests, 62 in the file. Every assertion was watched fail: 21 defects were injected one at a time and each turned at least one test red, including `iss` checked after the code is spent (2 red), the grant stored only after the loan succeeds (2), `login_hint` sent for an empty email (1), discovery inside a refresh left unbounded by the refresh timeout (1), and a credential written back to a cookie (1). Two first came back green. One was a defect in the test -- `parse_qsl` drops blank values, so an empty `login_hint` read as absent -- and one was a defect in the mutation, which moved the state into the challenge rather than the verifier and so was not the flaw it claimed to inject. `openlibrary/plugins/upstream/tests/` + `openlibrary/tests/core/` + `openlibrary/tests/accounts/`: 765 passed, 2 skipped. The 2 failures in `test_addbook.py` predate this branch and reproduce on a clean `origin/master`. pre-commit clean on the changed files, mypy and Generate POT included. No `provider_tokens` table exists yet -- the DDL is held back in #13689 pending Mek's ruling -- so this branch is stacked on that one and the callback fails closed until the migration lands. Refs #13685, #13687. Part of #12844. Claude-Session: https://claude.ai/code/session_01UViYm1nKqJkiKJWJUr1eas
The comment on `authorize_url` said the parameter was inert and that making it work was "three changes on the Lenny side", which read as though nobody had written them. They are written; they are on a branch that has not been pushed. The three places a node running `origin/main` drops it are correct and stay, as evidence for why such a node ignores it rather than as a claim about what exists. Also records the ceiling, because someone will ask for the other thing: even once it ships, `login_hint` pre-fills the email box and does not let the patron skip the email step. `/oauth2/authorize` is an unauthenticated GET, so mailing a one-time code on arrival would let a link or a prefetch send one to any address a caller chose. The privacy cost stays flagged as a cost and is unchanged: the hint discloses the address before consent, including on abandoned flows, and the delta over the completing case is small and real. That is a human's call, not this comment's. Comment only. 6,067 passed, 9 skipped, 1 xfailed; pre-commit clean. Claude-Session: https://claude.ai/code/session_01UViYm1nKqJkiKJWJUr1eas
A patron's loans page now shows the books they hold at a configured Lenny node alongside their Internet Archive loans (#13687). The merge happens in `account_loans.GET`, not inside `lending.get_loans_of_user`, and that placement is the change rather than an implementation detail. `get_loans_of_user` is not a display lookup: three of its five callers do something Internet Archive-specific with every element they get back. - `User.update_loan_status()` feeds each loan to `lending.sync_loan(loan["ocaid"])`. `account_loans.GET` calls it on the line before the lookup, so a loan with no ocaid there is a KeyError on the page the patron asked for. - `borrow.py` mints an Internet Archive bookreader link from `loan["_key"]` for whichever loan matches the edition. 21 of the 25 borrowable titles in the live feed sit on an edition that also has an `ocaid`, so a Lenny loan reaching that loop would produce a reader link for a loan the Internet Archive has never heard of. - `templates/account/loans.html` keys on `resource_type == 'bookreader'` and falls through to an `else` that reads `loan['loan_link']`. That template `else` is the #13690 shape again, and it is why the actions cell moved into `account/loan_actions.html`: the dispatch is the part that can be wrong, and inline it could only be reached by rendering the whole loans page -- a mock site, covers and a waiting list stand in front of it -- so nothing reached it. Rendered on its own, with the provider branch removed, a provider loan raises `KeyError('loan_link')` when it has no due date and silently advises returning the book through Adobe Digital Editions when it has one. Degradation, which is the requirement this feature turns on: - Nodes are queried concurrently, so four providers cost one timeout and not four. Measured rather than asserted from the shape of the code: against a sequential loop the wall-time test reports 1.61s against a 0.8s bound, while the nine other merge tests pass unchanged. - `provider_loans()` does not raise. A node that is slow, down, or answering nonsense costs its own entry in `unreachable`. - "Could not reach" and "needs reconnecting" are kept apart, for the reason `BORROW_ERRORS` already gives: they ask the patron to do different things. Two things the issue asked for that turned out not to exist as described: - The loans endpoint cannot come from discovery. RFC 8414 registers no field for a resource endpoint and `lennyforlibraries.org` advertises authorization, token and revocation and nothing else. Discovery still decides the origin; the path is built from the issuer, as `borrow()` already does. - The token phase cannot be parallelised alongside the HTTP phase. `access_token_for` is synchronous and takes a row lock, and `web.db.DB` keeps its connection in a `threadeddict` with `has_pooling` false (no `dbutils` in `oldev:latest`), so every worker thread touching `get_db()` opens a Postgres connection that is never released. It stays sequential in the request thread under a deadline. Claude-Session: https://claude.ai/code/session_01UViYm1nKqJkiKJWJUr1eas
The values in a loans payload are a record of which books a named patron borrowed from a library. The failure they are logged for is a shape mismatch, which the key names answer completely. Claude-Session: https://claude.ai/code/session_01UViYm1nKqJkiKJWJUr1eas
…timate 'One indexed read' understated it: the read is taken under FOR UPDATE. The variant that avoids the lock was built and measured on #13689 and is slower under contention, because the row an unlocked pre-check reads is expired precisely when a refresh is already in flight -- so it queues on the same lock anyway, having paid for an extra query. Claude-Session: https://claude.ai/code/session_01UViYm1nKqJkiKJWJUr1eas
…13688/lenny-borrow-popup
Clicking Borrow on a Lenny title opened another library's website in a new tab. The loan worked; it did not feel like Open Library. This puts the same navigation in a popup, so the page the patron started on is still there when the loan is made, and refreshes it. The popup's first page is the interstitial, carrying the one sentence that explains the relationship -- "borrowable for free from <library>, a trusted Open Library book provider" -- then it navigates to the node's authorize endpoint, the patron signs in and consents, and Open Library's callback exchanges the code and creates the loan. The callback's page then closes the popup and refreshes the opener. The interstitial is inside the popup rather than before it, which is a deviation from the issue's ordering and not a preference: `window.open` needs user activation, so a popup opened from a five-second countdown is blocked. Opening it on the click is the only version that works, and it costs the patron nothing -- they see the same two screens in the same order. A popup, not an iframe, and the reason is at the `window.open` call site because that is where someone will be standing when they propose simplifying it. The node's consent screen sets `X-Frame-Options: DENY` and `frame-ancestors 'none'` and has a test pinning them, so a frame renders nothing in any browser; and its session cookie is `SameSite=Lax`, so no browser would send it back from a cross-site frame. That cookie is the only thing the node's reader accepts. The issue gives this as a Safari/ITP tradeoff; it is not a tradeoff. Three things found on the way, each with its reason in the code: - The popup's endings are `delegate.RawText`, which looks like a styling choice. It is the only way the confirmation reaches the page behind the popup: `get_flash_messages()` drains `web.ctx.flash` as a side effect of reading it, the site layout calls it on every render, and `flash_processor` then writes no cookie because the drained value matches the empty one that arrived. - A signed-out patron was sent to `/account/login?redirect=<the book>`, which loses the click -- they arrive signed in, looking at the same button. Now they come back to the borrow they asked for. - The library is named from the node's own config rather than from the feed's provider name, because a node is a library and "Lenny" is the software. An operator who sets no `name` still gets a whole sentence. What this does not do: the refreshed book page still says "Borrow", not "Read". That button renders from the harvested acquisitions row, which a loan does not touch, and `get_lending_state` has no "patron holds a provider loan" state at all. Closing it needs per-patron loan state on a hot render path and must not go through `get_acquisitions`, which also feeds Solr. Until then the popup's success page keeps the node's read link for the paths with no opener, and the loan is confirmed by a flash on the refreshed page. Every test here was watched failing first: against a plain string instead of RawText, against the flash removed, against the interstitial's borrow wording dropped, against the mediated branch reverted, against the provider attribute deleted, against the message type renamed on one side only, and against the popup's origin check and its preventDefault ordering. Claude-Session: https://claude.ai/code/session_01UViYm1nKqJkiKJWJUr1eas
…13694 Three comment changes, no behaviour change. The popup's reliance on `Cross-Origin-Opener-Policy` being absent was written up as a finding -- true, checkable, and framed as a curiosity. It is a dependency, and one on a server Open Library does not own. The failure it produces is silent and will be attributed elsewhere: the loan is created, the popup closes, the page behind it never refreshes, nothing errors. A node operator could cause it tomorrow by hardening their own server for reasons having nothing to do with Open Library, and nobody would connect the two. So the call site now says what to check first and what the fix would be -- a BroadcastChannel between same-origin pages survives a context-group swap. The two places that explained why the success page keeps the node's read link pointed at "the PR for #13688". A PR thread is the wrong artifact for that: someone reading this code in a year is in an editor. They now point at openlibrary#13694, which tracks the button flip, and say to delete the link when it lands -- because that is the moment `read` stops being the only route to the book. Claude-Session: https://claude.ai/code/session_01UViYm1nKqJkiKJWJUr1eas
Two confirmed defects from independent review of the integration branch, both
this PR's.
**1. A timezone-aware due_at raised, taking the patron's Internet Archive
loans down with it.** The template fed the node's raw due_at to
datetime_from_isoformat -> parse_datetime (openlibrary/api.py:291), which is
re.split + int() on every token. An offset is not a token it can parse:
'2026-10-01T00:00:00+00:00' -> ValueError: invalid literal for int()
'2026-10-01T00:00:00Z' -> ValueError: invalid literal for int()
'2026-10-01T00:00:00-07:00' -> TypeError: tzinfo argument must be None
And that is the shape the node sends, verified against ArchiveLabs/lenny main
through the GitHub API rather than a local checkout: due_date and created_at
are both DateTime(timezone=True) (core/models.py:317,319) and the serialiser
emits a bare .isoformat() (routes/oauth2.py:543-544), which on Postgres carries
the offset.
The third case is why this is normalised in lenny.py rather than guarded at
the template: a negative offset splits into eight tokens and lands in tzinfo,
so it raises TypeError, not ValueError. A downstream guard catching the
obvious exception would still be taken down by a patron borrowing west of UTC.
borrowed_at was guarded from the start by _epoch; due_at was the one field
that bypassed this module and reached the template raw. Both now go through
one _parse_iso, and due_at is returned as naive UTC -- the shape Internet
Archive expiries already have here.
**2. A node 401 was shown as "could not be reached", permanently.** Every
exception was bucketed into unreachable. A grant that is locally unexpired but
rejected at the node stays locally unexpired forever, so the patron saw a
temporary outage that never ended and was never offered the one action that
fixes it. access_token_for cannot see this -- it only knows what the store
knows -- so this is the only place the distinction exists. 401 and 403 now
read as unauthorized; a node's 500 and a timeout still read as outages, which
is what keeps the new branch honest.
Also: return early from _patron_tokens when no node is configured. Every load
of /account/loans reached the token store, Lenny patron or not, and on a
deploy without #13689's migration that is a query against a table that does
not exist.
And two follow-ups from the same review, on judgement rather than instruction:
the notes now name the affected library rather than counting it -- these
arrive as provider names precisely so they can be named, and rendering only
len() made the lists pointless -- and the unauthorized note says how to
reconnect.
The fixtures were part of the defect. Both sides were handed naive
'2026-10-01T00:00:00', which is exactly the value that parses, so the unit
test and the template test each passed while nothing connected them.
TestTheExpiryTheNodeActuallySends now renders what loan_from_node returns from
what the node actually sends. Against the unfixed code 10 tests go red,
including that end-to-end pair.
Claude-Session: https://claude.ai/code/session_01UViYm1nKqJkiKJWJUr1eas
'It does not raise' was true while the loans page was broken: provider_loans never raised on a timezone-aware due_at, it passed the node's string through to a renderer that could not parse it, and the patron lost every loan they had. Not raising is not the property worth promising -- every value handed out of here being one the loans page can render is. Claude-Session: https://claude.ai/code/session_01UViYm1nKqJkiKJWJUr1eas
…loan The book page picked its CTA from acquisition.access alone, which is harvested from the provider's feed and so reads the same for every patron. Nothing on that path asked whether this patron already held a loan, so the borrow flow dead-ended on a page still offering Borrow for a book they had just borrowed. get_lending_state() cannot answer it either: it returns "partner" at step 2 for every non-IA provider and never reaches its loan checks, which are ocaid-keyed besides (openlibrary/core/lending.py:908, 938-947). The lookup therefore happens where the partner button is rendered. openlibrary/core/provider_loans.py puts memcache in front of lenny.provider_loans -- serving the previous answer while a new one is fetched on another thread -- and swallows every failure. A slow or broken node degrades to showing Borrow rather than delaying the book page, which is harmless: Lenny's borrow is idempotent for a loan the patron already holds, so a second attempt simply returns it. It deliberately does not go through get_acquisitions. That call feeds Solr's ebook_access, so making it per-patron would bake loan state into an index rebuilt far more slowly. The lookup is gated on check_loan_status and is_edition, keeping it off carousels, lists and search results. A loan whose read_url is missing or is not http(s) is ignored: Read with nowhere to send the patron is worse than Borrow, which at least works. Depends on #13691 for lenny.provider_loans, and on #13690 for the Lenny provider and the borrow branch this falls back to. Closes #13694 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
for more information, see https://pre-commit.ci
Three defects, two found by rendering a real partner edition locally and one by review: 1. The partner branch raised NameError: get_provider_loan was decorated @public, but nothing imports the module, so it was never registered as a template global and every partner edition page 500'd. The lookup now happens in render_read_button(), which takes check_loan_status instead of a pre-resolved loan, so no global is needed at all. 2. The editions table still said Borrow while the sidebar said Read, on the same page for the same edition. edition-sort.html now passes check_loan_status=render_first, which it already documents as "only true on the book page for an edition". 3. The lookup could block the render for the provider's full deadline. cache.memcache_memoize fetches synchronously on a miss and only refreshes in the background for a value already cached (openlibrary/core/cache.py: 112-115), so a cold cache put lenny.provider_loans's budget in front of the book page -- exactly what the issue forbids. It now reads memcache directly, answers with what it has, and starts any fetch on another thread. A failing memcache reads as no loans rather than raising. Also adds invalidate_provider_loans(), which the borrow flow should call once a borrow succeeds: otherwise the CTA can keep offering Borrow for up to PROVIDER_LOANS_TTL on the very page the patron just borrowed from. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Lenny names a node after its host once there is more than one of them -- `lenny`, then `lenny_<host>` (Lenny's own `_provider_name()`, shipped in 0.2.22). `_harvested_lenny_entries` reads exactly one of those names. So on the day a second library registers, its books lose their borrow button and nothing anywhere records it: the row is harvested, the lookup misses, and `get_acquisitions` returns an empty list indistinguishable from a book nobody lends. This does not fix that, and the button still vanishes. It adds one throttled WARNING naming the provider names that were harvested but cannot be read back, so a failure whose trigger is a third party standing up a node stops being invisible on this side. Deliberately not gated on the calling read having come up empty. The editions that break never reach `_harvested_lenny_entries` at all: they carry `identifiers.lenny_<host>`, and `get_book_providers` only yields a provider whose `get_identifiers` is non-empty, which reads `identifiers["lenny"]`. Every call that does arrive here is a healthy one, so gating on failure would keep this silent in exactly the case it exists for. A healthy render is the only trigger available. Silent on the healthy single-node case, which is production as recorded in this module on 2026-09-20: 94 works, all plain `lenny`. Bounded to one unindexed scan per process per hour -- `acquisitions` is indexed on work_id, edition_id and updated, not provider_name -- because this function runs once per Lenny edition per page render and a search page lists many. The deadline advances before the query, so a scan that fails costs the page once rather than once per remaining edition. The servable set is derived from what the lookup asks for rather than written as a literal, so widening the lookup silences this by itself instead of leaving a warning that outlived the defect it described. Claude-Session: https://claude.ai/code/session_01UViYm1nKqJkiKJWJUr1eas
…red here The docstring read "checked 2026-09-20", which claims a measurement this change did not make — it carried the figure from the module's existing record. An unsourced number is not a weaker claim than an unsourced assertion; it is a stronger-looking one, because a date reads as measured. Says where the figure comes from and tells the reader to re-derive it. Claude-Session: https://claude.ai/code/session_01UViYm1nKqJkiKJWJUr1eas
test_lenny.py conflicted through an open class body: HEAD added two more methods to `TestCallback` while #13691's side dedented to column 0 immediately. Concatenating theirs-first would have left those two methods inside `TestTheTokenPhaseIsBounded`, where they are silently not `TestCallback` tests and the suite stays green. Resolved by closing `TestCallback` with HEAD's two methods first, then both sides' top-level blocks. Verified by AST, not by the suite passing: collectable 79 (HEAD) + 90 (theirs) -> 110 merged, 0 orphaned, and the two apparent losses are #13691's `unparseable` -> `unparsable` renames, both present exactly once.
Two template conflicts, both against master rather than against the other Lenny branches. LoanStatus.html: #13706 calls `show_locate=secondary_action`, but master's #13721 folded `secondary_action` into `is_book_page` and removed the parameter -- `grep -rn secondary_action openlibrary/` now returns nothing. Taking #13706's side verbatim would have raised NameError on every partner book page. Kept master's `is_book_page` with #13706's new `check_loan_status=check_loan_status and is_edition` and its rationale. edition-sort.html: #13706's side taken as-is; `render_first` is still master's parameter name. AST collect, all six touched test files: 110/27/36/18/11/14, 0 orphaned.
… no table #13689's token store is a `provider_tokens` table. That table exists in no schema Open Library applies -- `core/schema.sql` does not declare it, there is no DDL-migration mechanism to add it, and the only place it has ever existed is the fixture in its own test file. Verified by running, on a dev database built from `schema.sql`: `\d acquisitions` describes a table, `\d provider_tokens` says "Did not find any relation". So every branch stacked on it was blocked on a migration nobody could land, which is why this has sat. This keeps #13689's public API exactly -- `Grant`, `ProviderToken.get`, `get_providers`, `upsert`, `delete`, `delete_all_by_username`, `get_fresh`, `TokenRefreshFailed` -- and replaces only the backend, behind a `TokenStore` protocol. `CookieTokenStore` Fernet-encrypts the whole grant set into one cookie using the same secret and helpers as the S3 keys. Swapping a durable store back in is one class and a `set_store()` call. WHAT THE COOKIE GIVES UP, stated here rather than discovered later: * It cannot single-flight a rotating refresh token. Two concurrent requests carry the same cookie, so both hold R0; the second to reach the node presents a spent token and loses the family. A row could be locked FOR UPDATE and re-read under the lock -- a cookie has nowhere to re-read from, because R1 exists only in the winner's response to the other tab. This is DEMO-SCOPED and must not be read as a design decision for production. `TestWhatTheCookieCannotDo` pins it as a characterisation test so a durable backend has to change it deliberately. * Cookies are capped near 4 KiB, so a patron with many grants loses the oldest -- logged, and ordered so the grant just authorized is the last evicted. * `delete_all_by_username` can only reach the patron whose request is in flight, so `anonymize` still needs a server-side store. Two fixes found by running, not by reading: * `save()` serialised `Grant` objects through `json.dumps(default=str)`, which silently wrote each one's *redacted* repr. The cookie round-tripped and held no tokens. Removed the `default=` fallback so an unserialisable value raises. * An unconditional `secure=True` -- what the `s3` cookie does -- means the browser drops the cookie on `http://localhost`, which is the one place this flow is demonstrated. `Secure` now follows the request scheme. Also here, both composition-only and green on every branch in isolation: * ruff F402 in `lenny.py`: #13693 imports gettext as `_`, #13691 adds a loop variable `_` in the same module. Latent, not live -- nothing calls `_()` after that loop yet -- but armed. * `test_book_providers.py` asserted a bare `>Read</a>`; master's #13721 ("Button label audit") moved every CTA label into a `cta-btn__label` span. Verified by RUNNING: 37 token-store tests pass, and all 8 mutations in a mutation battery were killed by their named target (username check, Secure flag, eviction order, failed-refresh clear, the `default=str` bug, expiry skew, repr redaction, unreadable-cookie handling) -- counts recorded per row, source restored clean afterwards. Claude-Session: https://claude.ai/code/session_01UViYm1nKqJkiKJWJUr1eas
`test_no_credential_reaches_the_browser` asserted `flow["cookies"] == []`, with
the docstring "The grant lives in `provider_tokens`, not in a cookie." Both are
now false of what ships -- and the test passed anyway, because the `flow`
fixture replaces `ProviderToken.upsert` with a recorder, so the assertion never
reached the store that writes the cookie. It would have passed with the
encryption deleted.
Replaced with the property that still has to hold: the callback writes exactly
one cookie, it is the token store's, and it contains neither token in
cleartext -- while remaining recoverable, which is the half a "looks like
gibberish" assertion would miss.
It puts the real `upsert` back to do it. That function has to be captured at
import time (`REAL_UPSERT`), because by the time the test body runs the fixture
has already rebound the attribute on the class, so reading it off
`ProviderToken` returns the stand-in rather than the original. Writing an
equivalent inline instead would have tested a copy of the store rather than the
store.
Verified by RUNNING: red with `encrypt_token` removed from
`CookieTokenStore.save` ("at-1" found in the cookie), green with it restored.
Also drops a now-stale `provider_tokens` row reference from a comment in
lenny.py that outlived the thing it described.
Claude-Session: https://claude.ai/code/session_01UViYm1nKqJkiKJWJUr1eas
… has it Recovers #13689's table-backed store, which the cookie displaced only because a database was off the table for the first demo. That constraint is lifted, so the table becomes the default and the cookie stays as a selectable backend. The seam #13865 introduced is kept and widened. `TokenStore` now carries the whole operation set rather than a coarse load/save pair, because `get_fresh` is the one thing the two backends do *differently*: `DbTokenStore` single- flights a rotating refresh token with `SELECT ... FOR UPDATE` on the patron's row, and expressing that through load/save would have imposed the cookie's limitation on the table. `ProviderToken` is now a facade over whichever store is installed and holds no storage logic of its own. `CREATE TABLE provider_tokens` goes into `openlibrary/core/schema.sql`, copied from the `POSTGRES_DDL` constant in `openlibrary/tests/core/test_provider_ tokens.py` — the copy that has actually been executed, which is why schema.sql was written from it and not the other way round. `TestTheShippedSchemaHasThe Table` compares the two on every run, so the fixture-only-table shape (109 green tests around a table that existed nowhere a deployment could reach it) cannot come back silently. schema.sql is applied at database INIT only and there is no DDL-migration mechanism — every script under scripts/migrations/ migrates data. So an existing database gets this table when an operator runs the statement by hand; the PR body carries that statement as a numbered deploy step. `TestWhatTheCookieCannotDo` is re-pointed rather than deleted. It is still a characterisation test of the cookie's single-flight race, but it now installs `CookieTokenStore` explicitly and says in its name and docstring that it is a statement about a non-default backend. The shipped default's behaviour is `TestSingleFlightUnderConcurrency`, which needs a real Postgres and is skipped in CI. In test_lenny.py, the callback's credential test splits in two: one asserting the shipped store writes no cookie at all (real `upsert`, real sqlite database, row read back decrypted — "no cookie" alone is also what a silently failed write looks like), and one keeping the cookie-backend ciphertext property with that backend installed by hand. `anonymize` is still not wired to `delete_all_by_username`, deliberately: adding DDL to schema.sql does not put the table in a database that already exists, so wiring it today would make account deletion raise on a missing relation in production. It is a one-line follow-up once an operator has run the DDL. Claude-Session: https://claude.ai/code/session_01UViYm1nKqJkiKJWJUr1eas
…ds them for Two things an operator deploying this to testing.openlibrary.org has to know and could not learn from conf/openlibrary.yml as it stood: * That file is read in development only. compose.production.yaml and compose.staging.yaml both set OL_CONFIG=/olsystem/etc/openlibrary.yml and mount ../olsystem, so on a deployed host the same keys go there. * schema.sql declares provider_tokens but an existing database will not have it, and the failure mode is every borrow raising on a missing relation. Also records that testing is https, so the `ptok` cookie's Secure flag is honoured there -- the silent-drop problem is specific to plain-http localhost. And a test correction worth stating rather than quietly fixing. The new `test_the_shipped_store_writes_no_cookie_at_all` installed `DbTokenStore()` itself, so flipping the module default back to the cookie left it green: it was testing a store it had supplied. Mutation run before: 8 of 9 killed, that one surviving. It now asserts the default and exercises it, and the same battery kills 9 of 9. Claude-Session: https://claude.ai/code/session_01UViYm1nKqJkiKJWJUr1eas
This was referenced Oct 9, 2026
`invalidate_provider_loans` was written for the borrow flow and said so -- "the borrow flow should call this once a borrow succeeds". It has exactly one call site in the tree, and it is its own unit test: `git grep invalidate_provider_loans` returns the definition and test_provider_loans.py:132, nothing else. Calling it would not have been enough either, which is the part worth recording. Dropping the entry does not make the next render show the new loan: `_cached_loans` answers "no loans" on a cold entry and only *starts* the fetch, because this module never blocks a render on a provider. So an invalidation leaves the CTA saying Borrow on the very page the patron just borrowed from, one request later. That is pinned by `test_invalidating_alone_still_leaves_the_page_saying_borrow`, which is green both before and after this commit -- it is a characterization of why the obvious fix is the wrong one, not a regression test. `prime_provider_loans` writes the loan instead. Merged into whatever is cached rather than replacing it, keyed on `book` so a patron who borrows the same edition twice ends with one entry, and stamped `fetched_at = 0.0` so the next render serves it *and* starts a real fetch behind it. The node stays the authority on the patron's holdings; the partial set written here survives one render. `_has_usable_read_url` is split out of `_is_readable_loan` so the write side and the read side cannot disagree about what counts as a usable link. No caller yet -- the next commit wires both borrow paths to it. Claude-Session: https://claude.ai/code/session_01UViYm1nKqJkiKJWJUr1eas
…correct Four things Mek reported after walking the live flow against lennyforlibraries.org on 2026-10-09. The loan worked end to end; everything around it was wrong. **"A long interstitial page when I clicked borrow."** Gone for Lenny. `handle_borrow_async` now redirects straight to `lenny_borrow`. That screen warns a patron the next page belongs to somebody else, and its own mediated wording said in as many words that this was not happening -- so it was five seconds spent denying a hazard it was built to announce. The handoff case is untouched: a node Open Library holds no credentials for still goes through the interstitial, because that one really does leave the site. The mediated wording and its three translatable strings are removed rather than left unreachable, and `mediated_borrow` stops returning a library name nothing reads any more. **"Shows 'Borrowed from Lenny' but did not bring me to the book... clicking borrow again retriggers the flow. When I refresh, it then shows read."** Not a popup or a postMessage failure: the flash proves the opener reloaded, so the channel worked. The reload re-rendered from the `provider_loans` memcache entry written *before* the borrow, and nothing in the flow had told that cache otherwise. `borrowed_page` now primes it, on both borrow paths. **"I'd like the book to open up in Lenny in a new window."** Three places render a Lenny reader link -- the book page CTA, the loans page, and the popup's own result page. (The list embed's `read_url` is not a fourth: `get_ebook_info` is gated on `ocaid` and a provider loan has none.) The book page CTA already carried `target="_blank"`, and is now pinned rather than changed; the other two did not and do. **And the popup ends in the book, not on a receipt.** Its last page posts to the opener and then navigates *itself* to the node's reader. A navigation, not a `window.open`: by the time the node has mailed a one-time code and the patron has typed it, the transient activation from the Borrow click is long gone and a popup opened from there would be blocked. Because it is the same window either way, the `target="_blank"` fallback for a blocked popup now reaches the reader too. `provider_borrow_popup.js` stops closing that window -- the two sides have to agree or the reader is killed as it loads. **The grant in `provider_tokens` is finally used.** `lenny_borrow` read no token at all: every click ran a full OAuth round trip for a credential already in the table, which was then overwritten with an equivalent one. It now borrows with the stored grant and falls back to the handshake when there is none, when the node rejects it (401/403 -- a revoked grant is locally unexpired forever, so a refused borrow is the only place it surfaces), or when the node does not answer. A refusal the handshake cannot fix -- "every copy is out", "you have too many books out" -- is shown instead of re-authorizing, which would mail the patron a code and reach the same answer. What this does not do: the node's reader wants the node's own browser session, and on the reused-grant path the patron may not have one, so they may be asked to sign in at the node before the book opens. That is what already happens to anyone opening a loan from last week. It is noted in `read_url` rather than solved here. Tests. Every assertion below was watched failing against unfixed source, and each implementation then survived a mutation battery (4 on the priming, 5 on the grant reuse, 5 on the popup script), every mutant caught by the test written for it. `tests/unit/js/provider_popup_result.test.js` is new and runs the popup's inline script *extracted from the shipped template* rather than a copy, which is coverage this feature had none of -- the Python side could only assert on source text, which cannot tell a navigation that happens from a string that is present. Claude-Session: https://claude.ai/code/session_01UViYm1nKqJkiKJWJUr1eas
…hips The priming tests swap in a `FakeMemo` that stores the Python object, so they never cross the one thing standing between a borrow and the next render: `memcache_memoize`, which round-trips every value through JSON. A loan carrying something `json.dumps` refuses would have passed all of them and failed on the first real borrow. These four use the shipping `get_cached_provider_loans` with the dummy client `memcache_memoize.memcache` itself falls back to, so `memcache_set` and `memcache_get` do their real encoding, and they build the loan with `lenny.loan_from_node` from a node's borrow response rather than hand-writing the result -- a fixture written to match the code tests nothing. The blind spots are measurably uncorrelated, which is the only reason both sets are worth keeping: with a `set()` written into the loan, these four go red and the eight fake-memo tests stay green. That check also turned up something worth writing down. A `datetime` is not such a value: `helpers.NothingEncoder` coerces any `date` to its ISO string, so it encodes cleanly and comes back a `str` -- a silent type change rather than an error. Nothing in a loan is a `datetime` today, so it is a note in the docstring and not a test. Claude-Session: https://claude.ai/code/session_01UViYm1nKqJkiKJWJUr1eas
Caught by re-reading the new code against the convention already in this module, not by a test and not by review. `_patron_tokens` wraps its `access_token_for` call in `except Exception` and explains why: the function absorbs `TokenRefreshFailed` and nothing else, so anything else arriving is the store or the node misbehaving. The commonest such failure is the one this PR documents as the default state of every existing deployment -- `schema.sql` is applied at database *init* only, so until an operator runs the `CREATE TABLE` by hand, `DbTokenStore` raises `relation "provider_tokens" does not exist` on every read. `borrow_with_stored_grant` is the second caller and did not guard it. The result would have been a strict regression on exactly the host this feature gets demoed from: before the borrow path read the store at all, a click there started the handshake and failed politely at the callback's `upsert` with "That library could not complete the loan"; with the unguarded read it 500s on the first click instead. It now falls through to the handshake, which restores that behaviour exactly. `test_an_unreadable_token_store_falls_back_to_authorizing_again` was watched raising `RuntimeError` out of the handler against the unguarded source, and it is the only test that goes red when the guard is narrowed back. Claude-Session: https://claude.ai/code/session_01UViYm1nKqJkiKJWJUr1eas
…er scheme Mek: "stop having agents litter our repo with paragraphs of comments and documentation in our code. You are wasting tokens and doubling or trippling the size of our code with comments inside our code that are not needed for trivial code." He is right and this is the correction. 284 lines of narration removed from the four commits before it -- incident dates, what-used-to-be-true history, rationale essays, and test docstrings restating the test's own name. The change is 881 added lines down to 638. Nothing is lost: that material is in these commit messages and the PR description, where `git blame` reaches it and the audience is someone asking why something changed. What is kept is the short form of anything a reader would otherwise break -- "provider_borrow_popup.js must NOT close this window", and the like. Two real fixes from the adversarial review, each with a test watched failing first and each killed by exactly one mutant: - **The popup severed its opener before becoming the node.** `location.replace` preserves `window.opener`, so the node would have held a live cross-origin handle to the patron's book-page window for the whole reading session. Every sibling link in this diff carries `rel="noopener"`; the path almost every patron takes had nothing. - **The reader URL is checked for http(s) before the window navigates to it.** `item_read_url` f-strings `issuer` in from config, and the stored-grant path never calls `discover()`, so it is the first path that reaches that URL without anything having exercised the issuer. Four stale comments corrected in the same pass, enumerated rather than sampled: the template header still said "the script below is what closes it", and `_popup_page`, `render_borrowed` and one test docstring still described a window that closes rather than one that navigates to the reader. Claude-Session: https://claude.ai/code/session_01UViYm1nKqJkiKJWJUr1eas
mekarpeles
marked this pull request as ready for review
October 10, 2026 19:02
tfmorris
pushed a commit
to tfmorris/openlibrary
that referenced
this pull request
Oct 12, 2026
…er_tokens, and schema.sql has the table (internetarchive#12844) (internetarchive#13865) feat(lenny): borrow from a Lenny node over OAuth 2.0 Patron borrows on Open Library, signs in and consents at the node, OL exchanges the code, creates the loan, and opens the book in the node's reader. - Grant stored in a provider_tokens table keyed on (username, provider_name), Fernet-encrypted like the S3 keys; cookie store kept as a selectable backend behind a TokenStore protocol. get_fresh single-flights rotating refresh tokens (SELECT ... FOR UPDATE), since a node revokes the family on replay. - Lenny registered in PROVIDER_ORDER below InternetArchiveProvider; access kind read from the harvested row, get_access overridden so borrowable titles aren't indexed as public. - Loans page merges node loans with IA loans, queried concurrently; 401/403 = reconnect, 500/timeout = unreachable; due_at normalised to naive UTC. - Book page shows Read once a provider loan is held; borrow runs in a popup that ends in the node's reader. - iss/state/S256/PKCE refusals each verified load-bearing; endpoints from /.well-known discovery; credentials from lenny_nodes config. Deploy: schema.sql declares provider_tokens at init only; existing DBs need the CREATE TABLE run by hand or every borrow raises. Refs internetarchive#12844, internetarchive#13685, internetarchive#13687, internetarchive#13270. Closes internetarchive#13694.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
State: ready-for-review
Updated: 2026-10-10T02:48Z
Owner: pr-13865-lenny-borrow-ux (the
provider_tokens/deploy half below was pr-13865-provider-tokens-table)Adversarial-review: subagent-ab034b0f
2e24cbcd5PASS — 11 findings, 3 fixed in2e24cbcd5, the rest listed under Next steps and What this does NOT doVerified by RUNNING — this revision (borrow-UX, head
2e24cbcd5)prime_provider_loans, 5 on the stored-grant reuse, 5 on the popup script, 1 on the unreadable-store guard, 2 on the opener/scheme guards. The reviewer independently ran 10 more mutants against the shipped popup script — 10/10 dead.tests/unit/js/provider_popup_result.test.jsruns the popup's inline script extracted from the shipped Jinja template, not a copy. This script had no coverage at all before. The reviewer verified the extraction claim rather than reading it: exactly one<script>tag, andsrc.slice(start,end) === match[1]istrue— byte-identical, cannot match nothing (the helper throws) or the wrong block.FakeMemo; four use the shipping memoizer somemcache_set/memcache_getdo real JSON encoding. With aset()in the loan, the four go red and the eight stay green.FAILEDlistsdiffempty — all pre-existing. Full JS suite: 64 files, 1263 tests, green.BorrowRedirect, since the mediated path moved from a Jinja-escaped attribute into aLocationheader./books/OL123\nMdoes reachmediated_borrow; Starlette percent-encodes it and web.py'sheader()raisesValueError("invalid characters in header")by design. The value is always prefixed/borrow/lenny/, so it cannot be an open redirect.pre-commitgreen on every changed file, on the host, sogenerate-potreally ran.pytest openlibrary/failure set isdiff-identical to the stashed baseline (20, all pre-existing, none mine);npx vitest run64 files / 1263 tests; CI 10/10.Verified by RUNNING — carried forward (
provider_tokens/ deploy)PASS; all three--breakmodes red at the step they name. The Postgres-gated single-flight tests ran againstpostgres:18.3(65 passed) and went red three times withFOR UPDATEmutated out. 9-of-9 mutation battery on the token store andschema.sql.Unverified / READ only
lennyforlibraries.org. Everything about the popup is jsdom plus reading: nobody has watched it resize, navigate, or leave the book page showing Read.window.resizeTohas never run outside jsdom. This is the biggest gap and one real borrow closes it.popup=yeswindow, so in Chrome it has no address bar — the patron reads a third-party book in a window whose origin they cannot see, and the resize to 1200×1000 is best-effort. Reviewer's finding; worth a product call from Mek rather than a silent default._refresh_in_background, which is the mechanism.ArchiveLabs/lenny0690a65:redirect_reader(api.py:314) andproxy_readium(api.py:327) takesession: Optional[str] = Cookie(None)and make zeroget_authenticated_identitycalls, anddocs/OAUTH2.md:249says it outright — "Returning a book and reading it still need the patron's login cookie." The OAuth grant does not confer reading. First borrow is fine (they just signed in at the node); a later one on a lapsed cookie is not. This bounds what grant reuse buys and is worth a decision: either the node accepts a bearer token for reading, or reuse saves the consent screen but not the sign-in.testing.openlibrary.org.Next steps
tests/e2e/ol_half.py:216will now raise.mediated_borrowreturnsstr | Noneinstead of(path, library). Two-line fix:path = offered, and assert the name againstlenny.node_display_name(...), where it is resolved now. I enumerated all 10lenny.*symbols that harness uses; this is the only signature I changed.state-less GET borrow wants a confirmation step now that the interstitial is gone. Note the latter is not new: the IA path has borrowed on a GET with the same cookie semantics all along.REJECTED_TOKEN_STATUSES' docstring describes the loans endpoint;borrow_with_stored_grantis now a second consumer where 403 may mean something else.invalidate_provider_loansstill has no production caller.OpenLibraryAccount.anonymizetoProviderToken.delete_all_by_username.Blocked on
Composes the six open Lenny drafts onto current
masterand gives the patron'sgrant a durable home: the
provider_tokenstable from #13689, with theCREATE TABLEinopenlibrary/core/schema.sqlso a fresh install gets it.Closes nothing on its own — this is the demo vehicle for #12844.
What changed since the last revision
The previous revision stored the grant in an encrypted cookie, because a
database was explicitly off the table for the first demo. That constraint is
lifted, so:
encryption, rotation under a row lock, and its ~450-line test suite.
CREATE TABLE provider_tokensis inopenlibrary/core/schema.sql, takenfrom the
POSTGRES_DDLconstant that has actually been executed.times, plus all three deliberate-failure modes.
The seam is kept and both backends ship.
DbTokenStoreis the default;CookieTokenStorestays as a demo/test backend for a database that has not hadthe DDL applied.
TokenStorenow carries the whole operation set rather than acoarse
load/savepair, becauseget_freshis the one thing the two backendsdo differently — the table single-flights a rotating refresh token with
SELECT … FOR UPDATEon the patron's row, and expressing that throughload/savewould have imposed the cookie's limitation on the table.ProviderTokenis now a facade holding no storage logic of its own.TestWhatTheCookieCannotDois re-pointed, not deleted. It is still acharacterisation test of the cookie's single-flight race, but it now installs
CookieTokenStoreby hand and says in its name and docstring that it is astatement about a non-default backend. What the shipped default does is
TestSingleFlightUnderConcurrency, which needs a real Postgres.The borrow flow after Mek's live walk (2026-10-09)
He borrowed "Catboy" (
OL26947526M) ontesting.openlibrary.orgagainst thereal
lennyforlibraries.org. The loan worked end to end — created at thenode, grant stored in
provider_tokens, loan visible on the book page after arefresh. Everything around it did not. His four reports, and what each turned
out to be:
1. "A long interstitial page when I clicked borrow." The five-second
countdown on
/books/<olid>/-/borrow?action=borrow. Mek's follow-up was toremove it for Lenny rather than skip it conditionally, and that is what this
does:
handle_borrow_asyncnow returnsBorrowRedirectstraight tolenny_borrow. The screen exists to warn a patron that the next page belongsto somebody else, and its mediated wording said in as many words that this was
not happening — so it was five seconds spent denying the hazard it was built to
announce. The handoff case keeps it: a node Open Library holds no
credentials for still gets the interstitial, because that one really does leave
the site. The now-unreachable
borrowingbranch and its three translatablestrings are deleted rather than left in place, and
mediated_borrowstopsreturning a library name nothing reads.
2 and 3. "Shows 'Borrowed from Lenny' but did not bring me to the book…
clicking borrow again retriggers the flow. When I refresh, it then shows
read." Not popup blocking and not a
postMessagefailure — this was thefirst hypothesis and it is wrong. The flash is written by
render_borrowedandrendered only by the site layout, which the popup deliberately skips, so seeing
"Borrowed from Lenny" on the book page is the opener having reloaded. The
channel worked.
What reloaded was a page re-rendering from the
provider_loansmemcache entrywritten before the borrow, because nothing in the borrow flow had ever told
that cache anything. The timeline fits exactly, including why the second load
worked:
_cached_loansfound a stale pre-borrow entry, returned[](so:Borrow) and started a background refresh; that refresh wrote the real set;
the manual refresh a moment later read it (so: Read).
invalidate_provider_loanswas written for precisely this and documented as"the borrow flow should call this once a borrow succeeds". It has exactly one
call site in the tree and it is its own unit test. Calling it would not have
fixed this either — a cold entry answers "no loans" and only starts the
fetch, by design, so the CTA would still have said Borrow on the reload. The
new
prime_provider_loanswrites the loan instead, stamped stale so the nodeis still asked behind it.
test_invalidating_alone_still_leaves_the_page_saying_borrowpins the trap and is green before and after.
Screenshots: none, and that is a real gap. No browser was available to me,
so there is nothing to show and nothing has been seen rendered. Every claim
about the popup comes from the jsdom harness over the shipped script plus
reading. The consent screens in Mek's screenshots are drawn by the node and are
not produced by this diff at all.
4. "I'd like the book to open up in Lenny in a new window." Three places
render a Lenny reader link: the book page CTA, the loans page, and the popup's
result page. (The list embed's
read_urlis not a fourth —get_ebook_infoisgated on
ocaidand a provider loan has none.) The book page CTA already hadtarget="_blank"; it is pinned by a test rather than changed. The other twodid not and now do.
And the popup ends in the book rather than on a receipt. Its last page
posts to the opener, then navigates itself to the node's reader. A navigation
rather than
window.open, because by the time the node has mailed a one-timecode and the patron has typed it the transient activation from the Borrow click
is long gone and a popup opened there would be blocked. Since it is the same
window either way, the
target="_blank"fallback for a blocked popup nowreaches the reader too — which is the "degrade better" half of item 2.
provider_borrow_popup.jsstops closing that window; the two sides have toagree or the reader is killed as it loads.
What "ends in the book" does and does not mean. The node's reader is
cookie-authenticated, not token-authenticated —
redirect_reader(
api.py:314) andproxy_readium(api.py:327) onArchiveLabs/lenny0690a65takesession: Optional[str] = Cookie(None)and make zeroget_authenticated_identitycalls, anddocs/OAUTH2.md:249says it outright:"Returning a book and reading it still need the patron's login cookie." So on
a first borrow the patron reads the book, because they signed in at the node
moments earlier. On a reused-grant borrow with a lapsed node session they
land on the node's sign-in instead. The grant does not confer reading, and that
bounds what grant reuse buys — see the status block.
The grant in
provider_tokensis finally used for borrowing.lenny_borrowread no token at all: every click ran a full OAuth round trip for a credential
already in the table, which was then overwritten with an equivalent one. It now
borrows with the stored grant, and falls back to the handshake when there is
none, when the node rejects it (401/403 — a revoked grant stays locally
unexpired forever, so a refused borrow is the only place that surfaces), or
when the node does not answer. A refusal the handshake cannot fix — "every copy
is out", "you have too many books out" — is shown instead, because
re-authorizing would mail the patron a code and reach the same answer.
Deploying to
testing.openlibrary.orgschema.sqlis applied at database init only, and Open Library has noDDL-migration mechanism — every script under
scripts/migrations/migratesdata. Testing has an existing database and will never re-run
schema.sql, sostep 3 is not optional and not a footnote: without it every borrow raises on
a missing relation.
1. Register Open Library as an OAuth client, on the node
On the Lenny node (
lennyforlibraries.org), as its operator:(which is
docker compose -p lenny exec api python3 scripts/oauth2_client.py ol-connect --redirect-uri …).It prints
client_idandclient_secret; the secret is printed once. TheURI=is required — the script's default ishttps://openlibrary.org/..., andthe redirect URI is matched byte-for-byte, including scheme, host and path.
2. Put the credentials in config — never in the repo
On the testing host, in the
olsystemcheckout that sits beside theopenlibraryone (compose mounts../olsystem→/olsystemand setsOL_CONFIG=/olsystem/etc/openlibrary.yml), add toetc/openlibrary.yml:Three things about this block:
lenny_nodesis theprovider_namethe harvest wrote intoacquisitions, which today is the literallennyfor every node (Lenny borrow: register Lenny as a book provider so the existing button offers it #13686).lenny_redirect_urimust be byte-identical to theURI=from step 1.issueris used to build the loans URL, while the token endpoint comesfrom discovery. A mismatch gives working loans and broken refresh — and a
failed refresh deletes the grant. The e2e refuses to proceed when the two
disagree; that is what
--break advertised-issuerexercises.openlibrary's ownconf/openlibrary.ymlis development-only and carries thissame block commented out.
3. Create the table by hand, on that host's database
UNIQUE (username, provider_name)is load-bearing, not decoration:ProviderToken.upsertis anON CONFLICT (username, provider_name) DO UPDATE,which Postgres rejects outright without that index — so the first repeat
borrow by any patron would be a 500. Confirm with:
This is byte-identical to
schema.sqland to thePOSTGRES_DDLconstant inopenlibrary/tests/core/test_provider_tokens.py; a test asserts all of that onevery run.
4. Make sure there is something to borrow
The Borrow button comes from an
acquisitionsrow, not fromidentifiers.lenny. For/books/OL59176589M("The Quintet", which carriesidentifiers: {'lenny': ['59176589']}on production):edition_idandlocal_idare OLID numerics (59176589), notthing.id.provider_nameis the literallenny, andlocal_idmust equal the value inidentifiers.lenny— that is the keyfind_manylooks the row up by.datablob must contain anaccesskey or the button renders nothing.5. Deploy the branch and restart
Normal testing deploy of
13552/lenny-oauth-demo, then restart web so theconfig is re-read.
6. Walk it
Sign in, open the book page, click Borrow. The patron goes to the node, signs
in there, consents, and lands back on
https://testing.openlibrary.org/borrow/lenny/callback; the loan should thenappear on
/account/loans.Testing is https, so the
ptokcookie'sSecureflag is honoured there.That flag only ever caused trouble on plain-http localhost, where an
unconditional
secure=Truemade the browser drop the cookie silently — a borrowthat appears to succeed and a loans page that stays empty. It is now keyed on the
request scheme. (With the table as the default store the cookie is not used for
the grant at all; this note is for anyone selecting the cookie backend.)
A caveat for the local walkthrough, not for testing
On production
/books/OL59176589Malso carries anocaidand aprovidersGitHub link, and both outrank Lenny in
PROVIDER_ORDER—LennyProvidersitsdeliberately below
InternetArchiveProvider, so an edition that alreadyrenders an IA button keeps rendering it. A local
copydocswalkthroughtherefore still needs those two fields removed by hand to make the Borrow button
appear. Do not "fix" the provider ordering here — that is deliberately
deferred.
What this does NOT do
lennyforlibraries.org. Every borrow in this PR'sevidence happened against a throwaway node the harness created and destroyed.
A completed borrow on the live node creates a real loan against real lending
limits on a real library, and is the operator's call to make once.
The e2e exercises the functions behind the pages, not the pages — no request
context, no templates, no infogami app. The popup's script now has a jsdom
harness that runs the real thing (
tests/unit/js/provider_popup_result.test.js),which is a long way from nothing, but no human has watched the popup resize,
navigate to the reader, or leave the book page showing Read.
window.resizeTohas never run outside jsdom. The COOP dependency is still unverified, and no
a11y tooling exists on
origin/masterfor page/template changes.mediated_borrownow returnsstr | Nonerather than(path, library), sotests/e2e/ol_half.py:216'spath, library = offeredwill raise. That is a two-line fix in the Lennyrepo and it is listed as a next step rather than done here.
node's reader wants the node's own browser session, which Open Library's
stored grant outlives; a patron borrowing with a reused grant may be asked to
sign in at the node before the book opens. Same as opening last week's loan.
Noted in
read_urlrather than solved.Step 3 is a human running one statement, and that is stated here rather than
discovered on deploy.
anonymize.ProviderToken.delete_all_by_usernameexistsand is tested, but
OpenLibraryAccount.anonymizeis not changed to call it,deliberately: adding DDL to
schema.sqldoes not put the table in a databasethat already exists, so wiring it today would make account deletion raise on a
missing relation in production. One line, once a host has run step 3.
acquisitions under the literal
lenny.they skip — a green check on this PR does not include them. They were run here
against
postgres:18.3, and the command is in the class docstring.Why the two DDL copies are compared rather than deduplicated
POSTGRES_DDLin the test file has to stay a literal constant: Lenny'stests/e2e/ol_half.pyparses it out by AST and executes it.schema.sqliswhere a fresh install gets the table. Two copies of a DDL drift silently and in
the worst direction — the tests keep passing against the shape they created
themselves, while a fresh install gets a different table. That is the
fixture-only-table shape that let 109 green tests sit around a table no
deployment had ever had, and it died on the last hop.
TestTheShippedSchemaHasTheTablecompares them, whitespace-insensitively, on every run, with a separate named
assertion for the
UNIQUEconstraint so a failure says which property went.Composed from
#13600 (borrow route and callback) · #13689 (token storage — now restored in
full, table and all) · #13690 (Lenny as a book provider) · #13691 (loans merge,
incl. the timezone fix) · #13693 (popup) · #13768 (unservable-node logging) ·
#13706 (Read-not-Borrow CTA, by @iamtanmaybaranwal)
https://claude.ai/code/session_01UViYm1nKqJkiKJWJUr1eas