# ADR-145 Re-authorise uploads via `/hub/?reauth=1` without clearing the shared session

## Status

**Accepted (dev only)** — 2026-08-26. Implemented in `hub-v46` and `imgbind-v15` on
`dev.srv1111289.hstgr.cloud`. **Not deployed to production** (the prod Hub is a separate
deployment; verified untouched at close-out).

**Provenance — read this before citing the ADR as authority.** The decision was taken **in
session** by Claude, endorsed by the advisor, and **reported to hkl in two written reports**. hkl
was told what changed and why. He did **not** explicitly approve this design before it shipped, and
he was not asked to. The open question it defers (the token TTL, below) **is** explicitly his.
Treat this as "built and disclosed", not "signed off".

## Status History

- 2026-08-26 — **Proposed and accepted the same session.** Written after ImageBinding was found to
  have no recovery path from a wiped upload token.
- 2026-08-26 — **Amended within the hour**: the first implementation (`hub-v44`) shipped a
  regression — it called `initLogin()` a second time, double-wiring the PIN keypad so one tap
  entered two digits. Fixed in `hub-v45` with an idempotence guard. The regression is recorded
  rather than edited out, per house rule: the wrong turn is part of the record.
- 2026-08-26 (evening) — **Amended a second time: the recovery path itself looped.** hkl reported
  *"demands login → login done → still demands login"*. Evidence (httpd + `hub.refresh_tokens` +
  `hub-media-proxy`, 18:23–18:25 IST): `/imgbind/?share=1` → `/hub/?reauth=1&next=%2Fimgbind%2F` →
  PIN → `/imgbind/` → PIN again 29 s later; **both logins minted a server-side token; zero
  `POST /upload` all day.** Cause: `attemptLogin()` called `captureHubRefreshToken()` fire-and-forget
  and then `location.replace(next)`; the SW-served navigation tore the document down before
  `hub.login` answered, so `lm360-hub-refresh` was never written — while the DB row existed. Fixed
  in `hub-v46`: the capture is awaited under a 6 s bound before any navigating exit, and on
  `?reauth=1` a failed capture stays on the Hub with a readable reason instead of bouncing back.
  ⭐ The v45 end-to-end test had hand-inserted the token digest "so no PIN was needed" — it skipped
  exactly the step that was broken. The v46 tests tap the PIN. Backups:
  `hub/index.html.bak-20260826-tokenrace`, `hub/sw.js.bak-20260826-tokenrace`.

## Context

ImageBinding uploads are authorised by an ADR-105 hub JWT, redeemed from a refresh token held in
`localStorage['lm360-hub-refresh']`.

Three facts combine into a dead end:

1. **Only a PIN login can mint that token.** `captureHubRefreshToken(empId, pin)` needs the PIN, so
   it runs only inside `attemptLogin()`. Nothing else writes the key.
2. **The Hub does not ask for a PIN while the session is alive.** `loadSession()` restores a valid
   `lm360-session` for 12 h, and `boot()` goes straight to the home screen.
3. **The app deletes the key on a definitive rejection.** `getHubJWT()` calls
   `localStorage.removeItem('lm360-hub-refresh')` on 400/401/403 — correct, since a dead token
   should not be retried forever.

So once the key was wiped, uploads were locked out with **no in-app route back**. The only
available workaround was **Logout** — which clears `lm360-session`, the **ADR-112 canonical key**
shared by every PWA on the device. The user would be signed out of everything to fix one app.

Measured on 2026-08-26: `media.items` had **0 rows created that day** and its newest live row was
**05-Aug**, while the server was entirely healthy (`/upload` accepted 12 MB, the refresh RPC
answered correctly, the secret was configured, disk 81 %).

## Decision

Add a **`?reauth=1`** branch to the Hub that shows the PIN screen for the **already-known**
employee and **leaves `lm360-session` completely untouched**.

- Additive only: a new query-param branch in `boot()`, taken **before** the existing session
  branch. Every other path is byte-for-byte unchanged.
- The employee is preselected from the existing session — the user re-enters a PIN, not an identity.
- An explanatory note renders on that screen only, so the prompt is not mistaken for a logout.
- Success re-runs the existing `captureHubRefreshToken()` and honours the existing validated
  `?next=` redirect back to the calling PWA — **after** the token is in localStorage (v46). The
  capture stays non-fatal to the login (bounded wait, 6 s), but on `?reauth=1` a failed capture
  keeps the user on the Hub with *"Signed in, but photo-upload authorisation failed — enter your
  PIN again to retry"*: the user came for the token, and bouncing him back without one is the loop.
- ImageBinding links to it from a banner (`/hub/?reauth=1&next=/imgbind/`) shown only when photos
  are queued **and** no token is present.

**Decision Maker:** Claude in session, advisor-endorsed, disclosed to hkl. See Provenance above.

## Implementation Notes

- `hub/index.html` — `?reauth=1` branch in `boot()`; `#reauth-note`; `_pinKeysWired` guard;
  `boot()` keeps the `initLogin()` promise as `loginReady` instead of starting a second call.
- `imgbind/index.html` — `#upload-auth-banner` + `renderUploadAuthBanner()`.
- 🚨 **The keypad guard is load-bearing, not cosmetic.** `initLogin()` attaches a click listener per
  `.pin-key` with no removal. Without the guard, any second call doubles every keypress. That is
  what `hub-v44` shipped.
- 🚨 **Anything the next page must see has to be written before `location.replace()`.**
  `captureHubRefreshToken()` now returns `true` only after `localStorage.setItem`, and
  `attemptLogin()` awaits it (`Promise.race` with a 6 s timeout) before the share-picker, `?next=`
  and home exits. That is what `hub-v45` got wrong — and the Hub SW being cache-first for every
  non-`/db/` GET meant the second reauth visit never even reached httpd, hiding the repeat.
- **Deployment ordering matters on a phone.** The imgbind banner targets a branch that exists only
  in `hub-v45`. If the Hub's service worker has not updated, the link lands on a v43/v44 Hub, which
  sees a valid session and silently shows the home screen — indistinguishable from "the fix did not
  work". Both service workers must update before the path is usable.

## Alternatives Considered

- **Tell the user to log out and back in.** Rejected: clears the ADR-112 canonical key and signs the
  device out of every PWA. Fixing one app by breaking the rest is not a recovery path.
- **Let ImageBinding collect the PIN itself.** Rejected: duplicates the authentication ceremony into
  a second app and spreads PIN handling across the estate. Auth belongs to the Hub.
- **Stop deleting the key on rejection.** Rejected: it would retry a dead credential indefinitely and
  hide the real state. The deletion is right; the missing piece was the way back.
- **Auto re-mint without a PIN.** Rejected here as out of scope — it is a change to the ADR-105 auth
  model, not a UI recovery path, and it is hkl's call. See "Does NOT govern".

## Consequences

- A user whose upload token has died can restore it in one screen without losing any other session.
- The Hub gains a second entry point to the login screen. Any future change to `boot()`'s session
  handling must consider both.
- `initLogin()` is now safe to call more than once — a property future edits may rely on.
- **Verified**: `hub-reauth.spec.js` 7/7, `imgbind-upload-retry.spec.js` 5/5, and all five imgbind
  tests fail on the unpatched file (including a real-token end-to-end upload). The keypad test was
  differential-proved against a reconstructed `v44`: one tap produced **2** digits there, 1 here.
- **Verified (v46)**: `hub-reauth.spec.js` **20 passed / 2 opt-in skipped** on android-chrome +
  desktop-chrome. New tests use a synthetic employee (`zz_hub_spec`, pgcrypto `crypt`, seeded in
  `beforeAll`, removed by literal id in `afterAll`, residue `0|0`) and **tap the PIN** with
  `rpc/login` delayed 1.5 s (`serviceWorkers: 'block'` so `page.route` sees it): the token is in
  localStorage when `/imgbind/` boots and a live `hub.refresh_tokens` row exists; a forced 500 on
  `?reauth=1` stays on the Hub with the reason, session intact, keypad live again; a plain `?next=`
  login still proceeds on failure. **Differential** (`HUB_DIFF=1`, serves the v45 backup via
  `route.fulfill`): *"v45 token after reauth: MISSING (bug reproduced)"*.
- **Deployment (v46)**: only the **Hub** service worker needs to update — imgbind is untouched.
  Fully close + reopen the Hub (or tap its Update banner), then imgbind → banner → PIN once.
- **Dev only.** No claim is made about production behaviour.

## Revisit If

- **hkl decides the token TTL** (see below) in a way that makes manual re-auth rare or unnecessary —
  a sliding renewal or auto re-mint would make this screen close to dead code.
- **The Hub gains real session management** (refresh-on-use, silent renewal, or SSO), at which point
  a PIN-gated re-auth screen may be the wrong primitive entirely.
- **A second PWA needs the same recovery**, which would argue for a shared helper rather than an
  imgbind-specific banner.
- **PIN entry needs different gating** (rate limiting, biometric, step-up auth) — this branch reuses
  the ordinary login lockout (5 attempts / 30 s) and inherits any change to it.
- **This ever reaches production**, which requires a deliberate decision, not a sync.

## Does NOT govern

- 🔴 **The 12-hour refresh-token TTL.** That is the ADR-105 question and it is **hkl's open
  decision**, not settled here. It is why the live ImageBinding album stops at 05-Aug: uploads
  silently die within 12 h of every Hub login. This ADR only provides a way back *after* the token
  is gone — it does not reduce how often that happens.
- The `lm360-session` 12 h TTL, or ADR-112's canonical-key rule (this ADR deliberately obeys it).
- ImageBinding's upload outbox retry behaviour — that is a separate fix in `imgbind-v15`.
- Production Hub deployment or its version.
