---
phase: 06-ux-polish
plan: '05'
subsystem: pwa/auth
tags: [tdd, auth, session-expiry, d-10, d-11]
dependency_graph:
  requires: []
  provides:
    - SessionExpiredError (apps/pwa/src/api/client.ts)
    - handleAuthResponse (apps/pwa/src/api/client.ts)
    - AuthSplash component (apps/pwa/src/components/AuthSplash.tsx)
    - sessionExpired flag (apps/pwa/src/store/calendarStore.ts)
    - QueryCache/MutationCache onError wiring (apps/pwa/src/main.tsx)
    - recurrenceUntil/recurrenceCount on CreateEventPayload (client.ts)
    - hasRrule on CalendarOccurrence client mirror (client.ts)
  affects:
    - Plan 06-06 (EventForm consumes recurrenceUntil/recurrenceCount payload fields and occurrence.hasRrule)
    - CalendarShell (auth-gated render replaces optimistic pre-auth paint)
tech_stack:
  added: []
  patterns:
    - TDD RED→GREEN for typed session-expiry detection (client.ts)
    - TanStack Query v5 global error handler via QueryCache/MutationCache constructor (NOT defaultOptions.onError)
    - Auth-gated render gate (meQuery.isSuccess required before CalendarContent paints)
    - One-shot redirect guard re-arm via clearLoginRedirect() + maybeRedirectToLogin()
key_files:
  created:
    - apps/pwa/src/components/AuthSplash.tsx
  modified:
    - apps/pwa/src/api/client.ts
    - apps/pwa/src/api/client.test.ts
    - apps/pwa/src/components/CalendarShell.tsx
    - apps/pwa/src/main.tsx
    - apps/pwa/src/store/calendarStore.ts
decisions:
  - D-10: unauthenticated cold load shows only the neutral AuthSplash ("Signing you in") — no calendar shell, skeleton, or pre-auth flash; CalendarContent renders only on meQuery.isSuccess
  - D-11: mid-use session expiry detected via SessionExpiredError (opaqueredirect || 401) across all fetch wrappers; global QueryCache/MutationCache onError sets sessionExpired flag → AuthSplash "Session expired / Signing you back in…" interstitial → redirect after 1.5s
  - TanStack v5: QueryCache({onError})/MutationCache({onError}) constructor pattern confirmed; defaultOptions.onError is removed in v5 and was NOT used
  - One-shot guard: clearLoginRedirect() re-arms the guard before maybeRedirectToLogin() in the session-expiry path (prevents redirect loop)
  - In-flight write replay deferred (D-11 nice-to-have, RESEARCH Open Question 3)
metrics:
  duration_minutes: 35
  completed_date: '2026-06-10'
  tasks_completed: 4
  files_changed: 6
---

# Phase 06 Plan 05: Auth-Flow Gating + Session-Expiry (D-10/D-11) Summary

**One-liner:** Typed `SessionExpiredError` centralized across all fetch wrappers (TDD), `AuthSplash` component gating the app render until `meQuery.isSuccess`, and a global `QueryCache`/`MutationCache` `onError` handler that surfaces a "Session expired" interstitial and redirects on any mid-use 401.

## Tasks Completed

| Task            | Name                                                                    | Commit              | Files                                         |
| --------------- | ----------------------------------------------------------------------- | ------------------- | --------------------------------------------- |
| 1 (RED)         | Failing SessionExpiredError detection tests                             | `e5072ff`           | client.test.ts                                |
| 1 (GREEN)       | Centralize session-expiry detection + extend client types               | `d7d4023`           | client.ts, client.test.ts                     |
| 2               | AuthSplash component + gate CalendarShell render on auth state          | `e7b34a5`           | AuthSplash.tsx, CalendarShell.tsx             |
| 3               | Global session-expiry interstitial via QueryCache/MutationCache onError | `139ef00`           | main.tsx, calendarStore.ts, CalendarShell.tsx |
| 4 (checkpoint)  | playwright-cli — no pre-auth flash; clean session-expiry redirect       | (verification only) | —                                             |
| Follow-up (RED) | Failing dead-end AuthSplash test for exhausted redirect guard           | `36ef7a0`           | CalendarShell.test.tsx                        |
| Follow-up (fix) | Make AuthSplash dead-end state reachable + persist redirect guard       | `e392c69`           | CalendarShell.tsx                             |

## What Was Built

### Task 1: SessionExpiredError + handleAuthResponse (TDD)

Added to `apps/pwa/src/api/client.ts`:

- `class SessionExpiredError extends Error` with `Object.setPrototypeOf(this, SessionExpiredError.prototype)` for reliable `instanceof` checks across TypeScript compilation boundaries.
- `handleAuthResponse(res, label)` helper: throws `SessionExpiredError` on `res.type === 'opaqueredirect' || res.status === 401`; throws a generic `Error` on other non-ok responses; passes through on ok.
- `redirect: 'manual'` added to all six fetch wrappers (`fetchEvents`, `createEvent`, `updateEvent`, `deleteEvent`, `fetchSyncStatus`, `fetchWritableCalendars`) — matching the existing `fetchMe` pattern.
- Client type additions (exclusive client.ts ownership): `recurrenceUntil?: string` and `recurrenceCount?: number` on `CreateEventPayload` (D-06 payload contract for Plan 06-06); `hasRrule: boolean` on the client-side `CalendarOccurrence` mirror matching `expand.ts` exactly (Pitfall 4 — atomic mirror).

TDD gate: RED commit (`e5072ff`) — tests failing with "SessionExpiredError is not a constructor". GREEN commit (`d7d4023`) — all client tests pass; 500 responses throw a generic Error, not SessionExpiredError.

### Task 2: AuthSplash + Gated CalendarShell (D-10)

Created `apps/pwa/src/components/AuthSplash.tsx`:

- Full-screen centered column (`height: 100dvh`, `--color-surface` background).
- Loader2 spinner (24px, `--color-member-0`, global `spin` keyframe).
- `role="status"` + `aria-label="Signing you in"`.
- `state: 'loading' | 'redirecting' | 'dead-end'` prop driving copy per UI-SPEC Surface 1:
  - `loading`: "Signing you in" / Loader2 spinner.
  - `redirecting`: "Taking you to the sign-in page…" / Loader2 spinner.
  - `dead-end`: "Sign-in required. Tap here to try again." — tap calls `clearLoginRedirect()` then `maybeRedirectToLogin()`.
- Logo lockup omitted gracefully (brand asset not present; no block on that).

`CalendarShell.tsx` updated:

- `meQuery.isLoading` → early-return `<AuthSplash state="loading" />` (no skeleton or calendar paints pre-auth).
- `meQuery.isError` → `<AuthSplash state="redirecting" />` (replaces the old `role="alert"` "Sign-in required" block; existing `maybeRedirectToLogin()` effect still fires).
- `CalendarContent`/`SkeletonCalendar` render only on `meQuery.isSuccess`.

### Task 3: Global Session-Expiry Interstitial (D-11)

`calendarStore.ts`: added `sessionExpired: boolean` (default `false`) + `setSessionExpired(v: boolean)` action.

`main.tsx`: `QueryClient` constructed with:

```ts
queryCache: new QueryCache({ onError(error) { if (error instanceof SessionExpiredError) setSessionExpired(true) } }),
mutationCache: new MutationCache({ onError(error) { if (error instanceof SessionExpiredError) setSessionExpired(true) } }),
```

TanStack Query v5 API confirmed via Context7 (`/tanstack/query`): `defaultOptions.onError` was removed in v5; `QueryCache`/`MutationCache` constructor `onError` is the correct path and always fires.

`CalendarShell.tsx`: when `sessionExpired` is true, renders `<AuthSplash state="redirecting" />` with the Surface-2 copy ("Session expired / Signing you back in…"); on mount schedules `clearLoginRedirect()` then `maybeRedirectToLogin()` after ~1.5s.

## Checkpoint Verification (Task 4)

Verified via playwright-cli against desktop Chromium with `DEV_AUTH_BYPASS=true`:

1. **Cold load (D-10):** First painted frame = neutral "Signing you in" splash (`role=status`). No calendar shell, SkeletonCalendar, or "Sign-in required" alert before the redirect toward `/api/login`. PASS.
2. **Mid-use 401 (D-11):** Intercepted `/api/events` returning 401 → "Session expired / Signing you back in…" interstitial appeared → navigated to `/api/login` within ~2s. No hang, no generic error. PASS.

## Follow-Up Fix After Checkpoint

The checkpoint surfaced two related issues:

1. **Dead-end state unreachable:** `CalendarShell` never rendered `AuthSplash state="dead-end"` — the render logic fell through to an empty fragment once the redirect guard was exhausted.
2. **One-shot redirect guard persistence:** The guard (`familysync.loginRedirectAttempted`) was cleared during the navigation to `/api/login`, so it was not available to the new page load; a fresh 401 immediately re-triggered the redirect loop.

Fix (commits `36ef7a0` RED, `e392c69` fix):

- `CalendarShell` now renders `<AuthSplash state="dead-end" />` once the redirect guard is exhausted after the interstitial fires.
- Guard persistence hardened: `clearLoginRedirect()` is called only at the point the user explicitly taps "Sign-in required. Tap here to try again." — not during the automatic redirect path.

Re-verified PASS via playwright-cli after fix.

## Residual Device-Only Item

**iOS-Safari standalone cold-load/redirect** remains a human/device checkpoint per 06-VALIDATION.md Manual-Only table. The standalone-mode OIDC redirect (no `window.location.href` cross-origin fallback) is not drivable in desktop Chromium.

## TDD Gate Compliance

- RED commit (`test(06-05): ...`): `e5072ff` — tests failing with `SessionExpiredError is not a constructor`
- GREEN commit (`feat(06-05): ...`): `d7d4023` — all client tests pass
- Follow-up RED: `36ef7a0` — failing dead-end guard test
- Follow-up fix: `e392c69` — guard and dead-end state corrected; full suite green
- RED→GREEN order confirmed via `git log`

## Deviations from Plan

### Auto-fixed Issues

**1. [Rule 1 - Bug] Dead-end AuthSplash state unreachable + redirect guard not persisting**

- **Found during:** Task 4 (playwright-cli checkpoint)
- **Issue:** CalendarShell never rendered `AuthSplash state="dead-end"` (fall-through to empty fragment); the one-shot redirect guard was cleared during navigation, not on user tap, making the guard unavailable to the landing page on a fresh 401.
- **Fix:** CalendarShell now renders the dead-end state once the redirect guard exhausts; guard is cleared only on explicit user tap in the dead-end handler.
- **Files modified:** `apps/pwa/src/components/CalendarShell.tsx`
- **Commits:** `36ef7a0` (RED), `e392c69` (fix)

## Threat Flags

None beyond the plan's STRIDE register. T-06-05-info (pre-auth render gate), T-06-05-redirect (one-shot guard prevents redirect loop), T-06-05-session (opaqueredirect || 401 detection only — no token read), T-06-05-inflight (in-flight write replay deferred, documented). No new surfaces introduced.

## Self-Check: PASSED

- `apps/pwa/src/components/AuthSplash.tsx` — FOUND (created)
- `apps/pwa/src/api/client.ts` — FOUND (`class SessionExpiredError`, `handleAuthResponse`, `hasRrule`, `recurrenceUntil`)
- `apps/pwa/src/main.tsx` — FOUND (`MutationCache`, `QueryCache`; no `defaultOptions.onError`)
- `apps/pwa/src/store/calendarStore.ts` — FOUND (`sessionExpired`, `setSessionExpired`)
- Commit `e5072ff` — FOUND (RED: test(06-05))
- Commit `d7d4023` — FOUND (GREEN: feat(06-05))
- Commit `e7b34a5` — FOUND (feat(06-05): gate app render)
- Commit `139ef00` — FOUND (feat(06-05): global session-expiry interstitial)
- Commit `36ef7a0` — FOUND (test(06-05): dead-end guard RED)
- Commit `e392c69` — FOUND (fix(06-05): dead-end state reachable)
