# ADR-026: Cross-PWA Session Handoff Uses Shared localStorage on Same Origin

## Status

Accepted, 2026-06-25.

## Status History

```yaml
status_history:
  - date: 2026-06-25
    status: Proposed
    changed_by: hkl
    reason: Formalising session bridge mechanism used between /client/ and /activity/
    changed_via: adr-kit (360lm)
  - date: 2026-06-25
    status: Accepted
    changed_by: hkl
    reason: Pattern live and stable; activity and client PWAs use it
    changed_via: adr-kit (360lm)
```

## Context

Some PWAs delegate authentication to another PWA (e.g. `/activity/` has no login form — users arrive via `/hub/` for employees or `/client/` for external clients). After login at the source PWA, the destination PWA must recognise the session without prompting for credentials again. All 360lm PWAs are on the same origin (`srv1111289.hstgr.cloud`), which means they share `localStorage` — a session key written by one PWA is immediately readable by any other PWA on the same origin.

There are also two parallel client identity systems that must not be confused:

| System | Auth | Session key | TTL | Used by |
|---|---|---|---|---|
| Portal clients | ID + 6-digit PIN | `lm360-client-session` | 30 days | `/client/` home |
| Activity clients | company code + access code | `lm360-activity-session` | 8 hours | `/activity/` |

## Decision

Session handoff between same-origin PWAs uses `localStorage` key sharing. The source PWA (e.g. `/client/`) writes the exact session key the destination PWA (e.g. `/activity/`) expects, then redirects. The destination PWA's `restoreSession()` reads that key on load — no server call needed.

Session keys are treated as a shared contract. The format for each key is fixed:
- `lm360-session` — hub employee session `{empId, name, role, loginAt}` — 12h TTL
- `lm360-activity-session` — activity client session `{id:'client:X', name, company, type:'client', ts}` — 8h TTL
- `lm360-client-session` — portal client session `{id, displayName, loginAt}` — 30 days
- `lm360-hub-refresh` — ADR-105 web-JWT mint seam `{refresh_token, obtained_at, empId}` — capture-only in hub, 12h hard boundary (see 2026-08-02 note below)

Any PWA that writes or reads a session key MUST use the exact key name and format above. Never create a new session key without documenting it here.

Security: `?next=` redirect values must be validated to start with `/` before following — prevents open redirect to external URLs (see ADR-001).

**Decision Maker:** hkl

## Alternatives Considered

- **Server-side session with cookie.** Rejected: requires a session service that stores and validates tokens; adds a server round-trip on every PWA load; `localStorage` on same origin is simpler and provides the same isolation guarantee.
- **URL-based token passing (`?session=<token>` in redirect URL).** Rejected: token appears in browser history, bookmarks, and server logs; can be accidentally shared; `localStorage` keeps the session off the URL.
- **JWT issued by an auth service.** Rejected: requires a token-issuing service, JWT library, expiry handling, and signature verification — significant infrastructure for what is achieved by a plain JS object in `localStorage` on a same-origin intranet app.
- **`window.postMessage` between PWA windows.** Rejected: only works when both windows are simultaneously open; redirect-based flows close the source window before the destination opens.
- **Each PWA has its own login form.** Rejected: activity and client PWAs can be reached from multiple source PWAs; duplicating login forms across destinations creates divergence and forces users to re-authenticate on every PWA switch.

## Consequences

**Positive:**
- Zero server round-trip for session handoff — `localStorage` read is synchronous.
- Works immediately after redirect — no race conditions with async auth endpoints.
- Simple to implement: one `localStorage.setItem()` at source, one `localStorage.getItem()` at destination.

**Negative / Trade-offs:**
- All session-bridging PWAs MUST be on the same origin — cross-domain handoff is impossible with this pattern.
- `localStorage` is synchronous and blocks the main thread for large session objects — keep session objects small (< 1 KB).
- If a session key format changes, ALL PWAs reading that key must be updated simultaneously.
- Two parallel client session systems (`lm360-client-session` vs `lm360-activity-session`) must not be confused — they serve different PWAs and have different formats.

**Risks and mitigations:**
- Session key collision (new PWA accidentally uses existing key name): breaks sessions for existing PWAs silently. Mitigated: this ADR documents all session keys; check here before creating a new key.
- XSS attack reads session from localStorage: mitigated by same-origin isolation; all PWAs are served from the same trusted origin, no third-party scripts included.
- Stale session after 8h/12h: destination PWA's `restoreSession()` checks `ts + TTL > Date.now()` and clears expired sessions.

## Related Decisions

- ADR-001 (hub ?next= redirect) — same ?next= pattern used by client portal for session bridge.
- ADR-012 (hub as SSO) — employee session (lm360-session) written by hub; readable by all PWAs.
- ADR-018 (client auth) — client portal writes lm360-activity-session for activity PWA bridge.

## References

- `pwa_dev_style.md` §23 — Client Portal Session Bridge Pattern (full implementation)
- `client/index.html` — writes lm360-activity-session + lm360-client-session
- `activity/index.html` — reads lm360-activity-session and lm360-session

---

## Implementation Note (2026-08-02) — New shared-contract key: `lm360-hub-refresh` (ImageBinding chunk D21-A)

D21-A wired hub's PIN login (`hub/index.html`, `attemptLogin()`) to **also** call the
already-existing `hub.login(p_id,p_pin)` RPC (same PIN just verified via `verify_pin`) and
stash the returned `refresh_token` in a new key, `lm360-hub-refresh`:
`{refresh_token, obtained_at, empId}`. This is the client-side half of the seam ADR-105's
2026-08-02 addendum (chunk B5a) identified as the real gap: `hub.login`/`hub.refresh_hub_token`
already existed and mint real ADR-105 JWTs, but no web PWA login flow captured a
`refresh_token` to redeem them with.

**Contract:**
- **Written by:** `hub/index.html` only, immediately after a successful PIN login. Non-fatal —
  if `hub.login` fails or is unreachable, `verify_pin`-based login proceeds exactly as before and
  no key is written (hub remains usable even if this seam is down).
- **Read by:** any web PWA's `getHubJWT()` helper, which POSTs the stored `refresh_token` to
  `/db/rpc/refresh_hub_token` (`Content-Profile: hub`) to mint a fresh 30-min access token —
  no PIN prompt. `refresh_hub_token` returns no new refresh token, so **the refresh token's own
  ~12h lifetime is the hard re-login boundary**; once it expires, `getHubJWT()` must clear this
  key and treat the PWA as logged out of the upload seam (falls back to "hold pending" — see
  ImageBinding B3/B5b).
- **Cleared by:** hub's `clearSession()` (fires on logout and on `lm360-session` TTL expiry), so
  it never outlives the employee session it was minted alongside.
- This key is **capture-only in hub** — no other PWA writes it, matching hub's role as sole
  PIN-verifier (ADR-012). PWAs only ever read + redeem it.

Full investigation, rejected alternative (`hub.mint_jwt(session)` — forgeable, not built), and
verification evidence: ADR-105 addendum (2026-08-02) and ImageBinding Plan 2.0 §7, B5a/D21-A
entries.
