Skip to content

ADFA-5361 fix(portal): mint the Calibre-Web session for the WebView, and stop the guest cookie shadowing it - #511

Merged
luisguzman-adfa merged 5 commits into
mainfrom
fix/ADFA-5361-books-admin-session-lost
Sep 1, 2026
Merged

ADFA-5361 fix(portal): mint the Calibre-Web session for the WebView, and stop the guest cookie shadowing it#511
luisguzman-adfa merged 5 commits into
mainfrom
fix/ADFA-5361-books-admin-session-lost

Conversation

@luisguzman-adfa

Copy link
Copy Markdown
Collaborator

Books opened as Guest after adding content through Get more, permanently — the owner could not
manage the books they had just added.

Cause. Flask-Login binds a session to the requesting agent. dash-node minted under its own
agent, so the WebView's first request had it rejected, the remember_token deleted, and an
anonymous session planted at Path=/books, outranking anything the app set at / and never
cleared. The ADFA-5043 auto-login never delivered admin at all; the "logged in as Admin" flash
renders from the injected session even when the viewer is anonymous, which is why it looked fine.
Two launchers also opened Calibre-Web unauthenticated, because the service was an Intent extra
only one of them remembered to pass.

Evidence, options, cookie map and open consequences:
ADR-5361.

Changes

  • The box mints the session for the agent that asks (app sends its WebView UA; dash-node
    forwards it through the handshake, Calibre-Web and Kolibri).
  • One owner for "opens as box admin" — derived from the URL, not passed as an extra.
  • The cookie jar is reconciled, not appended to: cleared on every path before the fresh set.
  • The failure is legible: logged, retried once for fast failures only, and surfaced (existing
    string, no new l10n).
  • dash-node: the books runner's duplicate login is gone; it never got the ADFA-5043
    remember_me, which is the drift this bug rode in on.

−1 Intent extra, −3 call sites that had to know, −1 login implementation. No new state or strings.

connectedAndroidTest now refuses to run

It uninstalls both APKs when it finishes, which on a device holding a rootfs deletes the box
one run destroyed an installed system while writing this. build.gradle fails it with the safe
invocation in the message; -PallowUninstall=true opts out.

Verification — nothing outstanding

:app:testDebugUnitTest + :app:lintDebug green (URL→service rule, cookie directives, retry
against a scripted server). Instrumentation 3/3: a deeper-path cookie outranks the root one and
Max-Age=0 deletes at its path. dash-node yarn typecheck clean, 91/91 tests, on the box's
own Node 22. On device: all three entry points open as Admin, a full Get more → add → reopen
cycle keeps admin with Edit Metadata and delete working, Courses unaffected, and a forced 401
shows the toast, logs HTTP 401 with no retry line, and recovers. The jar was wiped first, so the
result is attributable.

Follow-ups left open are in ADR §5. dash-node 1.2.11 (the runner dedupe is not REST-facing, so it
rides in the same entry).

… reconcile the cookie jar

The Books card opened as box admin only when the launcher remembered to pass an
AUTH_SERVICE extra, and two of the three launchers did not: a local book opened from
"your books" (the screen reached right after Get More) and the module action sheet loaded
Calibre-Web unauthenticated, leaving a guest session in the shared WebView cookie jar. The
injection then only ever set cookies at "path=/", which appends rather than replaces, so a
same-name cookie at the served prefix survived and outranked the fresh one — that is how a
single guest page load became a permanent guest session.

PortalActivity now derives the service from the resolved target URL (AutoLoginPolicy), so
no call site can forget it, and clears the service's cookies on every path they can live
at before installing the fresh ones (SessionCookies). Both are pure and JVM-unit-tested. A
failed sign-in leaves the jar untouched, so a still-valid remember-me is not discarded over
a transient failure.

Removes the AUTH_SERVICE extra and LibraryHomeFragment.authServiceFor(). No new state, no
new strings.
…nd stop shadowing it

Opening Books never actually signed you in as the box admin. Three defects stacked:

1. The auto-login service was an Intent extra each launcher had to remember, and two of
   the three did not (a local book from "your books" — the screen reached right after Get
   More — and the module action sheet), so those opened Calibre-Web unauthenticated.
2. The session cookies were only ever set at "path=/", which appends rather than replaces:
   the box fronts Calibre-Web under /books, and a same-name cookie at that deeper path
   outranks ours in the Cookie header (RFC 6265 5.4) and was never cleared.
3. The session was minted under dash-node's own agent. Flask-Login binds a session to a
   fingerprint of the User-Agent, so the WebView's first request had it rejected: identity
   dropped, remember_token deleted, guest session planted at /books. Measured on device —
   the same cookies render Admin under the minting agent and Guest under the WebView's.
   The "logged in as Admin" flash renders from the injected session even when anonymous,
   which is why this looked like it worked.

PortalActivity now derives the service from the target URL (AutoLoginPolicy), clears the
service's cookies on every path they can live at before installing the fresh ones
(SessionCookies), and asks the box to mint the session for the WebView's own User-Agent,
which dash-node forwards through the whole login handshake for Calibre-Web and Kolibri.
The callers that consume a session themselves keep dash-node's agent.

The three land together on purpose: the clear pass alone would destroy the manual sign-in
that is currently the only way to administer the library.

Removes the AUTH_SERVICE extra and LibraryHomeFragment.authServiceFor(). Pure logic is
JVM-unit-tested; an instrumentation test pins the cookie reconciliation against the real
WebView store (device-verified). No new state, no new strings. dash-node 1.2.10 -> 1.2.11.
…icate Calibre-Web login

The auto-login failure path was invisible and self-poisoning: AuthClient swallowed the
exception without a single log line, and PortalActivity just loaded the page, so a failed
sign-in looked exactly like a permissions problem in Calibre-Web rather than a sign-in that
did not happen. It now logs the real cause (which already distinguishes 401 from 503 from a
timeout), retries once for the failures worth retrying, and tells the user, reusing the
string the Authentication screen already shows for this fact.

Retried are the FAST failures — a refused connection or a 5xx, what a content service that
is restarting or saturated by a books job answers in milliseconds — after a short pause,
because those are precisely the failures an immediate retry asks again too soon to get a
different answer. A timeout is not retried: it already spent the full read budget, and
asking again buys the same answer for twice the wait with the overlay on screen. A 4xx is
not retried either; it is the box's final word. Same rule the box uses for its own retries
(sockets/net-retry.ts).

dash-node: the books runner's private copy of the Calibre-Web login is gone; it imports
getCalibreSession. Two implementations of one fact had already drifted — the ADFA-5043
remember_me reached one and not the other — which is the drift this whole bug rode in on.
The runner consumes its own session, so it passes no consumer User-Agent.

No new strings, no new state. dash-node stays at 1.2.11: not REST-facing, so by convention
it does not bump, and the change rides in that version's changelog entry.
…nnot reach

On a healthy box the UI never reaches AuthClient's retry: a card whose service is down is
not Ready, so it opens the action sheet instead of the portal, and Get More hides the entry
outright — both device-verified. The retry exists for the narrow race the bug rode in on,
so a scripted local server is the only way to hold that case still.

Six cases against a socket that answers a scripted sequence: a 5xx is retried and the second
answer is used; the retry pauses before asking again (an immediate one would ask a restarting
service too soon to get a different answer); a persistent 5xx gives up after exactly one
retry; a 401 and a 404 are final and asked only once — the server has a 200 queued behind
them that the test requires us never to reach; and a first-try success pays neither a second
request nor the pause.

Plain JVM, no emulator: fetchCookie touches only HttpURLConnection and Log, so it runs in
the normal unit-test task. It drops from private to package-private for this, with a comment
saying why.
…sumer, and refuse the task that wipes the box

The auto-login of ADFA-5043 never delivered admin: Flask-Login binds a session to a fingerprint of
the requesting agent, so one minted by the box was rejected on the WebView's first request, which
also deleted the remember cookie and planted an anonymous session under the service's path prefix —
where it outranked anything the app installed at the root. The ADR records the measured evidence,
the decision (mint for the agent that asks; one owner for "opens as admin"; reconcile the jar), the
options rejected, and the consequences left open.

It also records the two things that cost the most time: the flash message that renders from the
injected session even when the viewer is anonymous, which is why a broken feature looked healthy,
and the fact that the failure path is unreachable through the UI, so it is covered by a test rather
than by device steps.

build.gradle now refuses connectedAndroidTest unless -PallowUninstall=true. That task uninstalls
both APKs when it finishes, and on a device holding an installed rootfs that deletes the box — one
run destroyed an installed system while this ticket was being written. The safe invocation is in the
error message and in the ADR.
@luisguzman-adfa
luisguzman-adfa merged commit 94bf354 into main Sep 1, 2026
4 checks passed
@luisguzman-adfa
luisguzman-adfa deleted the fix/ADFA-5361-books-admin-session-lost branch September 1, 2026 06:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant