Skip to content

Lenny OAuth borrow, composed and unblocked: the grant lives in provider_tokens, and schema.sql has the table (#12844) - #13865

Merged
mekarpeles merged 35 commits into
masterfrom
13552/lenny-oauth-demo
Oct 10, 2026
Merged

mekarpeles merged 35 commits into
masterfrom
13552/lenny-oauth-demo

Conversation

@openlibrary-bot

@openlibrary-bot openlibrary-bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

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 2e24cbcd5 PASS — 11 findings, 3 fixed in 2e24cbcd5, the rest listed under Next steps and What this does NOT do

Verified by RUNNING — this revision (borrow-UX, head 2e24cbcd5)

  • Every new assertion was watched failing against unfixed source first, and each implementation then survived a mutation battery with every mutant killed by the test written for it: 4 on 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.js runs 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, and src.slice(start,end) === match[1] is true — byte-identical, cannot match nothing (the helper throws) or the wrong block.
  • The priming tests exist in two sets with measurably uncorrelated blind spots. Eight use a FakeMemo; four use the shipping memoizer so memcache_set/memcache_get do real JSON encoding. With a set() in the loan, the four go red and the eight stay green.
  • Full Python suite, with a control: run with the change and again with it stashed. 20 failures both times, sorted FAILED lists diff empty — all pre-existing. Full JS suite: 64 files, 1263 tests, green.
  • Header-injection probe on the new BorrowRedirect, since the mediated path moved from a Jinja-escaped attribute into a Location header. /books/OL123\nM does reach mediated_borrow; Starlette percent-encodes it and web.py's header() raises ValueError("invalid characters in header") by design. The value is always prefixed /borrow/lenny/, so it cannot be an open redirect.
  • New tests run 3× each, stable; nothing in them reads a clock except a constant.
  • pre-commit green on every changed file, on the host, so generate-pot really ran.
  • Re-run at this exact head after the commentary trim: 238 Lenny-specific tests pass; the full pytest openlibrary/ failure set is diff-identical to the stashed baseline (20, all pre-existing, none mine); npx vitest run 64 files / 1263 tests; CI 10/10.

Verified by RUNNING — carried forward (provider_tokens / deploy)

  • A borrow completed end to end against a real throwaway node, 4 runs, all PASS; all three --break modes red at the step they name. The Postgres-gated single-flight tests ran against postgres:18.3 (65 passed) and went red three times with FOR UPDATE mutated out. 9-of-9 mutation battery on the token store and schema.sql.

Unverified / READ only

  • No browser, and no borrow against lennyforlibraries.org. Everything about the popup is jsdom plus reading: nobody has watched it resize, navigate, or leave the book page showing Read. window.resizeTo has never run outside jsdom. This is the biggest gap and one real borrow closes it.
  • The popup is a popup=yes window, 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.
  • A background loans refresh started just before the borrow can clobber the primed entry and re-stamp it fresh, re-exposing the original "still says Borrow" for up to 60s. Likely a sub-second window in practice. The test suite is structurally blind to it — the real-cache fixture stubs _refresh_in_background, which is the mechanism.
  • The reused-grant path lands the patron on the node's sign-in, not the book, whenever their node session has lapsed. No longer "may" and no longer reasoned — measured on ArchiveLabs/lenny 0690a65: redirect_reader (api.py:314) and proxy_readium (api.py:327) take session: Optional[str] = Cookie(None) and make zero get_authenticated_identity calls, and docs/OAUTH2.md:249 says 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.
  • The deploy steps below have still not been run on testing.openlibrary.org.

Next steps

  • Lenny repo: tests/e2e/ol_half.py:216 will now raise. mediated_borrow returns str | None instead of (path, library). Two-line fix: path = offered, and assert the name against lenny.node_display_name(...), where it is resolved now. I enumerated all 10 lenny.* symbols that harness uses; this is the only signature I changed.
  • Operator runs the deploy steps below and completes one real borrow — which is also the browser verification this revision lacks.
  • Decide on the chromeless popup (above) and on whether 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_grant is now a second consumer where 403 may mean something else.
  • invalidate_provider_loans still has no production caller.
  • After step 3 has been run on a host, wire OpenLibraryAccount.anonymize to ProviderToken.delete_all_by_username.
  • Lenny borrow: register Lenny as a book provider so the existing button offers it #13686 (per-node provider names) is still open.

Blocked on

  • Nothing. Mek for merge.

Composes the six open Lenny drafts onto current master and gives the patron's
grant a durable home: the provider_tokens table from #13689, with the
CREATE TABLE in openlibrary/core/schema.sql so 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:

  1. Lenny borrow: encrypted per-provider OAuth token storage (#13685) #13689's table-backed store is back, recovered rather than rewritten —
    encryption, rotation under a row lock, and its ~450-line test suite.
  2. CREATE TABLE provider_tokens is in openlibrary/core/schema.sql, taken
    from the POSTGRES_DDL constant that has actually been executed.
  3. The borrow was exercised for real, against a live (throwaway) node, four
    times, plus all three deliberate-failure modes.

The seam is kept and both backends ship. DbTokenStore is the default;
CookieTokenStore stays as a demo/test backend for a database that has not had
the DDL applied. 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 — the table 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 holding no storage logic of its own.

TestWhatTheCookieCannotDo is re-pointed, not deleted. It is still a
characterisation test of the cookie's single-flight race, but it now installs
CookieTokenStore by hand and says in its name and docstring that it is a
statement 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) on testing.openlibrary.org against the
real lennyforlibraries.org. The loan worked end to end — created at the
node, grant stored in provider_tokens, loan visible on the book page after a
refresh. 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 to
remove it for Lenny rather than skip it conditionally, and that is what this
does: handle_borrow_async now returns BorrowRedirect straight to
lenny_borrow. The screen exists to warn a patron that the next page belongs
to 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 borrowing branch and its three translatable
strings are deleted rather than left in place, and mediated_borrow stops
returning 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 postMessage failure — this was the
first hypothesis and it is wrong. The flash is written by render_borrowed and
rendered 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_loans memcache entry
written 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_loans found 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_loans was 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_loans writes the loan instead, stamped stale so the node
is still asked behind it. test_invalidating_alone_still_leaves_the_page_saying_borrow
pins 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_url is not a fourth — get_ebook_info is
gated on ocaid and a provider loan has none.) The book page CTA already had
target="_blank"
; it is pinned by a test rather than changed. The other two
did 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-time
code 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 now
reaches the reader too
— which is the "degrade better" half of item 2.
provider_borrow_popup.js stops closing that window; the two sides have to
agree 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) and proxy_readium (api.py:327) on ArchiveLabs/lenny
0690a65 take session: Optional[str] = Cookie(None) and make zero
get_authenticated_identity calls, and docs/OAUTH2.md:249 says 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_tokens is finally used for borrowing. 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 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.org

schema.sql is applied at database init only, and Open Library has no
DDL-migration mechanism
— every script under scripts/migrations/ migrates
data. Testing has an existing database and will never re-run schema.sql, so
step 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:

make ol-connect URI=https://testing.openlibrary.org/borrow/lenny/callback

(which is docker compose -p lenny exec api python3 scripts/oauth2_client.py ol-connect --redirect-uri …).
It prints client_id and client_secret; the secret is printed once. The
URI= is required — the script's default is https://openlibrary.org/..., and
the 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 olsystem checkout that sits beside the
openlibrary one (compose mounts ../olsystem → /olsystem and sets
OL_CONFIG=/olsystem/etc/openlibrary.yml), add to etc/openlibrary.yml:

lenny_redirect_uri: https://testing.openlibrary.org/borrow/lenny/callback
lenny_nodes:
  lenny:
    name: Lenny for Libraries
    issuer: https://lennyforlibraries.org
    client_id: <from step 1>
    client_secret: <from step 1>

Three things about this block:

  • The key under lenny_nodes is the provider_name the harvest wrote into
    acquisitions, which today is the literal lenny for every node (Lenny borrow: register Lenny as a book provider so the existing button offers it #13686).
  • lenny_redirect_uri must be byte-identical to the URI= from step 1.
  • issuer is used to build the loans URL, while the token endpoint comes
    from 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-issuer exercises.

openlibrary's own conf/openlibrary.yml is development-only and carries this
same block commented out.

3. Create the table by hand, on that host's database

CREATE TABLE provider_tokens (
    id serial primary key,
    username text not null,
    provider_name text not null,
    access_token text not null,
    refresh_token text default null,
    expires timestamp without time zone default null,
    scope text not null default '',
    created timestamp without time zone default (current_timestamp at time zone 'utc'),
    updated timestamp without time zone default (current_timestamp at time zone 'utc'),
    UNIQUE (username, provider_name)
);

UNIQUE (username, provider_name) is load-bearing, not decoration:
ProviderToken.upsert is an ON 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:

\d provider_tokens

This is byte-identical to schema.sql and to the POSTGRES_DDL constant in
openlibrary/tests/core/test_provider_tokens.py; a test asserts all of that on
every run.

4. Make sure there is something to borrow

The Borrow button comes from an acquisitions row, not from
identifiers.lenny. For /books/OL59176589M ("The Quintet", which carries
identifiers: {'lenny': ['59176589']} on production):

INSERT INTO acquisitions (work_id, edition_id, provider_name, local_id, data)
VALUES (<work OLID numeric>, 59176589, 'lenny', '59176589',
        '{"access": "borrowable"}'::jsonb);
  • edition_id and local_id are OLID numerics (59176589), not thing.id.
  • provider_name is the literal lenny, and local_id must equal the value in
    identifiers.lenny — that is the key find_many looks the row up by.
  • The data blob must contain an access key or the button renders nothing.

5. Deploy the branch and restart

Normal testing deploy of 13552/lenny-oauth-demo, then restart web so the
config 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 then
appear on /account/loans.

Testing is https, so the ptok cookie's Secure flag is honoured there.
That flag only ever caused trouble on plain-http localhost, where an
unconditional secure=True made the browser drop the cookie silently — a borrow
that 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/OL59176589M also carries an ocaid and a providers
GitHub link, and both outrank Lenny in PROVIDER_ORDER — LennyProvider sits
deliberately below InternetArchiveProvider, so an edition that already
renders an IA button keeps rendering it. A local copydocs walkthrough
therefore 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

  • It does not borrow from lennyforlibraries.org. Every borrow in this PR's
    evidence 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.
  • It does not run the web app under test, and nobody has opened a browser.
    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.resizeTo
    has never run outside jsdom. The COOP dependency is still unverified, and no
    a11y tooling exists on origin/master for page/template changes.
  • It does not update Lenny's e2e harness. mediated_borrow now returns
    str | None rather than (path, library), so tests/e2e/ol_half.py:216's
    path, library = offered will raise. That is a two-line fix in the Lenny
    repo and it is listed as a next step rather than done here.
  • It does not make the reused-grant path guarantee a reader session. The
    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_url rather than solved.
  • It does not migrate an existing database. It cannot: there is no mechanism.
    Step 3 is a human running one statement, and that is stated here rather than
    discovered on deploy.
  • It does not wire anonymize. ProviderToken.delete_all_by_username exists
    and is tested, but OpenLibraryAccount.anonymize is not changed to call it,
    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. One line, once a host has run step 3.
  • It does not fix Lenny borrow: register Lenny as a book provider so the existing button offers it #13686 (per-node provider names). The button still resolves
    acquisitions under the literal lenny.
  • It does not run the concurrency tests in CI. They need a real Postgres, so
    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_DDL in the test file has to stay a literal constant: Lenny's
tests/e2e/ol_half.py parses it out by AST and executes it. schema.sql is
where 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. TestTheShippedSchemaHasTheTable
compares them, whitespace-insensitively, on every run, with a separate named
assertion for the UNIQUE constraint 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

mekarpeles and others added 30 commits September 10, 2026 14:21
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
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
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>
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
messages.pot taken from master unresolved-by-hand; it is generated and is
regenerated with `pre-commit run generate-pot --all-files` at the end of
this composition.
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.
test_book_providers.py conflicted through an open class body, same shape as
#13691's: ours is all indented methods, #13768's side dedents at column 0 from
its first line.  Ours first, then theirs.  AST: 24 + 34 -> 36 collectable,
0 orphaned, union exact (no test missing, none invented).
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
`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
mekarpeles marked this pull request as ready for review October 10, 2026 19:02
@mekarpeles
mekarpeles merged commit 6faa99f into master Oct 10, 2026
13 of 14 checks passed
@mekarpeles
mekarpeles deleted the 13552/lenny-oauth-demo branch October 10, 2026 19:03
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Lenny borrow: register Lenny as a book provider so the existing button offers it

3 participants