# Phase 24: Public Access & Infrastructure - Research

**Researched:** 2026-04-10
**Domain:** Auth middleware bypass, client-side routing for anonymous users, rate limiting tiers
**Confidence:** HIGH

## Summary

Phase 24 removes the login wall from all read-only public routes and adds tiered rate limiting to protect public endpoints. The server-side public allowlist in `src/server/index.ts` (lines 121-140) already includes the four public GET endpoints needed (`/api/global-items`, `/api/tags`, `/api/users/:id/profile`, `/api/setups/:id/public`). The client-side root layout (`__root.tsx`) currently has two blocking problems: it shows a full-page spinner while auth resolves, and it hard-redirects any unauthenticated visitor who isn't on `/users/*` or `/login` directly to `/login` via `window.location.href`. Both must change.

The `TotalsBar` component already conditionally renders a "Sign in" link for anonymous visitors vs. the `UserMenu` for authenticated users — this part is already done. The primary client-side work is: (1) expand `isPublicRoute` to include `/global-items/*`, `/setups/*` (public view context), and `/`; (2) remove the blocking spinner and the hard redirect; (3) add an inline auth-prompt modal for write action interception. The rate limiter currently has a single hardcoded 5 req/15 min tier that is only appropriate for OAuth endpoints — it needs a factory function producing configurable tiers for browse (higher) and sensitive (low) use.

**Primary recommendation:** Make `__root.tsx` render-first by removing the `authLoading` spinner gate and the `window.location.href` redirect; widen the public route list; add a `createRateLimit(max, windowMs)` factory to `rateLimit.ts`; apply appropriate tiers to public GET endpoints in `index.ts`.

---

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions

- **D-01:** Keep `requireAuth` as default middleware on `/api/*`. Expand the existing allowlist of public GET routes that skip auth (current pattern: regex checks in `src/server/index.ts`).
- **D-02:** Categories stay auth-gated — they are user-scoped organizational data. Public browsing uses tags (already public via GET `/api/tags`).
- **D-03:** Public setup views include the owner's category names as read-only display context (data already returned by GET `/api/setups/:id/public`).
- **D-04:** Expand the `isPublicRoute` check in `__root.tsx` to include catalog routes (`/global-items/*`), public setup views, and the root `/`. Keep login redirect only for truly private routes (`/collection`, `/settings`, `/threads`).
- **D-05:** No changes needed for TotalsBar — it's already only shown in collection views, not in the global header. (**Note from research: TotalsBar IS the global header; it already handles the Sign-in vs UserMenu conditional — no changes needed.**)
- **D-06:** When an anonymous user attempts a write action (add to collection, create thread, etc.), show an inline popup/modal saying "To manage your own collection, sign in or sign up" with links to both. Do NOT hard-redirect to `/login`.
- **D-07:** Apply rate limiting to all public GET endpoints. Current rate limiter needs new tiers — the existing 5 req/15 min is only appropriate for OAuth.
- **D-08:** Same rate limits for authenticated and anonymous users — no exemptions.
- **D-09:** Fire-and-forget auth check — render the page immediately, check `/api/auth/me` in the background. Anonymous users see content right away with no spinner or redirect.
- **D-10:** Show a "Sign in" button in the top-right corner on all public pages for anonymous visitors. When authenticated, replace with the existing user menu.

### Claude's Discretion

- Rate limit numbers: Claude picks appropriate limits per endpoint type (browse, search, detail). Start with reasonable defaults, expect tuning later.

### Deferred Ideas (OUT OF SCOPE)

None — discussion stayed within phase scope.
</user_constraints>

---

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| PUBL-01 | User can browse the global item catalog without logging in | Server allowlist already includes GET `/api/global-items/*`; client `isPublicRoute` must add `/global-items/*` |
| PUBL-02 | User can view public setups without logging in | Server allowlist already includes GET `/api/setups/:id/public`; client must add `/setups/*` to public routes and render a public-safe view for anonymous visitors |
| PUBL-03 | User can view user profiles without logging in | Server allowlist already includes GET `/api/users/:id/profile`; client already treats `/users/*` as public |
| PUBL-04 | Anonymous visitors see the landing page without auth spinner or redirect | Root layout has `authLoading` spinner gate and `window.location.href` hard redirect — both must be removed |
| PUBL-05 | Login is only required when user attempts to create/edit/delete their own data | Write action buttons across the app need to check `isAuthenticated` and show the auth prompt modal instead |
| INFR-01 | Public API endpoints are rate-limited to prevent abuse | `rateLimit.ts` needs configurable tiers; new limits applied to public GET routes in `index.ts` |
</phase_requirements>

---

## Standard Stack

### Core (all already in project — no new installs)

| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| Hono | ^4.12.8 | Server middleware and routing | Already in use; `app.use()` middleware chains support per-path rate limit application |
| TanStack Router | ^1.167.0 | Client-side routing | Already in use; `useLocation` and `useMatchRoute` are the tools for `isPublicRoute` logic |
| TanStack React Query | ^5.90.21 | Auth state management | `useAuth()` hook returns `{ data: auth, isLoading }` — the `isLoading` gating in `__root.tsx` is what to remove |
| React 19 | ^19.2.4 | UI | Already in use |

### No New Dependencies Required

All required functionality is achievable with existing libraries. The rate limiter refactor is purely internal to `rateLimit.ts`. The auth popup modal follows the existing pattern of inline dialogs already present in `__root.tsx` (CandidateDeleteDialog, ResolveDialog).

**Installation:** None required.

---

## Architecture Patterns

### Recommended Approach: Rate Limit Factory

The existing `rateLimit` middleware is a single closure with hardcoded limits. Convert it to a factory function:

```typescript
// src/server/middleware/rateLimit.ts
export function createRateLimit(maxAttempts: number, windowMs: number) {
  return async function rateLimit(c: Context, next: Next) {
    cleanup();
    const ip = getClientIp(c);
    const key = `${ip}:${c.req.path}`;
    // ... same logic, uses maxAttempts and windowMs
  };
}

// Keep the original export for backward compatibility (OAuth usage)
export async function rateLimit(c: Context, next: Next) {
  return createRateLimit(MAX_ATTEMPTS, WINDOW_MS)(c, next);
}
```

Tiers to create (Claude's discretion — reasonable defaults):

| Tier | Max | Window | Applied to |
|------|-----|--------|------------|
| `browseTier` | 120 req | 1 min | GET `/api/global-items`, GET `/api/tags` |
| `detailTier` | 60 req | 1 min | GET `/api/global-items/:id`, GET `/api/setups/:id/public`, GET `/api/users/:id/profile` |
| `sensitivesTier` | 5 req | 15 min | `/login`, `/api/auth/setup`, OAuth endpoints (existing behavior, unchanged) |

Rationale: A catalog browse page may make 1 list + N detail requests in a session. 120/min for list endpoints and 60/min for detail endpoints allows normal browsing while still blocking automated scraping. These are generous defaults — D-08 says no auth exemptions so they apply equally to all callers.

### Server-Side: Applying Rate Limits in `index.ts`

Apply rate limits as middleware before the `requireAuth` block, scoped to the public GET paths:

```typescript
// After db injection, before requireAuth block
const browseTier = createRateLimit(120, 60_000);
const detailTier = createRateLimit(60, 60_000);

app.use("/api/global-items", async (c, next) => {
  if (c.req.method === "GET") return browseTier(c, next);
  return next();
});
app.use("/api/global-items/:id", async (c, next) => {
  if (c.req.method === "GET") return detailTier(c, next);
  return next();
});
app.use("/api/tags", async (c, next) => {
  if (c.req.method === "GET") return browseTier(c, next);
  return next();
});
// etc.
```

### Client-Side: `__root.tsx` Restructure

**Current flow (broken for PUBL-04):**
1. Render spinner while `authLoading === true`
2. After auth resolves: if unauthenticated AND not public route → `window.location.href = "/login"`

**Required flow (D-09):**
1. Render immediately — no `authLoading` gate
2. Auth resolves in background; UI updates silently (FAB appears, write buttons enable)
3. If unauthenticated AND route is private (`/collection`, `/settings`, `/threads`) → redirect to login

**Key change in `isPublicRoute`:**
```typescript
// Current
const isPublicRoute =
  location.pathname.startsWith("/users/") || location.pathname === "/login";

// Required
const isPublicRoute =
  location.pathname === "/" ||
  location.pathname.startsWith("/users/") ||
  location.pathname.startsWith("/global-items") ||
  location.pathname.startsWith("/setups/") ||  // public setup detail view
  location.pathname === "/login";
```

**Note on `/setups/$setupId.tsx`:** The current setup detail page is fully authenticated — it includes Add Items, Delete Setup, and Public toggle buttons. For anonymous visitors hitting a `/setups/:id` URL, the page must render only read-only content. The approach: check `isAuthenticated` before rendering write action buttons (same pattern as `showFab`). The underlying data comes from the private `GET /api/setups/:id` endpoint which IS auth-gated. Either: (a) anonymous visitors get the public endpoint response instead (`/api/setups/:id/public`), or (b) a separate route `setups/$setupId.public.tsx` for unauthenticated views. Option (a) is simpler — the public endpoint already exists and is in the server allowlist.

**Recommended:** Add `/setups/$setupId` to `isPublicRoute` and have the setup detail page detect auth state to decide which API endpoint to call (`useSetup` vs `usePublicSetup`). Since `useSetup` will return 401 for anonymous users anyway, the cleanest approach is to always call the public endpoint for unauthenticated visitors and the private endpoint when authenticated.

### Auth Prompt Modal Pattern

New component `AuthPromptModal` (or inline dialog in root): follows the same pattern as `CandidateDeleteDialog` and `ResolveDialog` in `__root.tsx` — a fixed overlay with a centered card. State managed via Zustand `uiStore`.

```typescript
// In uiStore.ts — add:
showAuthPrompt: boolean;
openAuthPrompt: () => void;
closeAuthPrompt: () => void;
```

```typescript
// Modal content:
<h3>Sign in to manage your collection</h3>
<p>To manage your own collection, sign in or sign up.</p>
<a href="/login">Sign in</a>
<a href="/login">Create account</a>  {/* Logto handles signup at same endpoint */}
```

Note: Logto handles both sign-in and sign-up at the `/login` OIDC redirect. The two links can both go to `/login` — Logto's UI presents options. The UX distinction is in the button labels ("Sign in" vs "Create account"), not different URLs.

### Write Action Interception

All write action buttons that anonymous visitors could encounter on public pages need this guard:

```typescript
// Pattern: check isAuthenticated before write action
function handleAddToCollection() {
  if (!isAuthenticated) {
    openAuthPrompt(); // from uiStore
    return;
  }
  // existing add logic
}
```

Pages with write actions reachable by anonymous visitors:
- `/global-items/$globalItemId` — "Add to Collection" button, "Add to Thread" button
- `/setups/$setupId` (public view) — no write actions needed in read-only view
- `/users/$userId` — read-only, no write actions

The catalog pages are the primary concern.

### Anti-Patterns to Avoid

- **Hard redirecting in `__root.tsx`:** `window.location.href = "/login"` causes a full page reload and is hostile to visitors who haven't signed up. Replace with `navigate({ to: "/login" })` for private-only routes, and only after auth resolves — not during loading.
- **Per-path rate limit keys without normalization:** The current store key is `${ip}:${c.req.path}`. Dynamic segments like `/api/global-items/123` vs `/api/global-items/456` create separate buckets. For detail endpoints, this is fine (per-item limits). For list endpoints, the path is always `/api/global-items` so no issue.
- **Blocking render on auth:** The existing `authLoading` return early renders a spinner. This violates D-09 — remove it entirely.
- **Memory leak in rate limit store:** The `cleanup()` function is called on every request — this is fine for low-traffic but grows with unique IPs. Not a concern for this phase; document as future optimization.

---

## Don't Hand-Roll

| Problem | Don't Build | Use Instead |
|---------|-------------|-------------|
| IP extraction from proxy headers | Custom header parsing | Existing `getClientIp()` in `rateLimit.ts` already handles `x-forwarded-for` |
| Auth state in components | Local `useState` for auth | Existing `useAuth()` hook — already cached by React Query |
| Modal overlay | Custom CSS backdrop | Follow existing `CandidateDeleteDialog` pattern in `__root.tsx` — `fixed inset-0 z-50` with `bg-black/30` backdrop |
| Zustand store additions | New store | Extend existing `uiStore.ts` with `showAuthPrompt` boolean |

---

## Common Pitfalls

### Pitfall 1: `/setups/$setupId` is an Authenticated Route
**What goes wrong:** Adding `/setups/` to `isPublicRoute` lets the anonymous user past the redirect, but `useSetup(id)` calls `GET /api/setups/:id` which requires auth. The request returns 401, the component shows "Setup not found."
**Why it happens:** The private setup endpoint is auth-gated (not in the allowlist). The public variant is `/api/setups/:id/public`.
**How to avoid:** In the setup detail page, detect `isAuthenticated`. If unauthenticated, call `usePublicSetup(id)` (new hook wrapping GET `/api/setups/:id/public`). If authenticated, call `useSetup(id)` as before. Render only read-only content when using the public hook.
**Warning signs:** 404 or empty page for anonymous visitors on `/setups/:id`.

### Pitfall 2: Onboarding Loading Gate Still Blocks
**What goes wrong:** After removing the `authLoading` spinner, the `onboardingLoading` spinner (lines 147-154 in `__root.tsx`) still blocks render for unauthenticated users.
**Why it happens:** `useOnboardingComplete()` calls an auth-required endpoint. For unauthenticated users, it returns an error, and `onboardingLoading` may be `true` briefly.
**How to avoid:** The `showWizard` check already guards on `isAuthenticated` — `useOnboardingComplete` should be disabled/skipped when not authenticated. Use `{ enabled: isAuthenticated }` in the query options.
**Warning signs:** Anonymous visitors see a spinner on first render even after removing the auth spinner.

### Pitfall 3: Rate Limiter `_resetForTesting` Not Exported for New Tiers
**What goes wrong:** If `createRateLimit` uses a shared store or the test reset only clears the original store, new-tier tests bleed state between tests.
**Why it happens:** The store `Map` is module-level. Multiple tier instances share it.
**How to avoid:** Either (a) pass a store instance per tier (cleanest), or (b) keep the single module-level store and export `_resetForTesting` (it clears the whole store — fine for tests). Option (b) is simpler given existing test pattern.

### Pitfall 4: TotalsBar "Sign in" vs D-10 Conflict
**What goes wrong:** D-10 says "Show a 'Sign in' button in the top-right corner on all public pages." The TotalsBar already does this (verified in `TotalsBar.tsx` lines 39-47). But D-05 was misread in the context as "no changes needed for TotalsBar." TotalsBar IS the global header.
**Why it happens:** Context note says "TotalsBar only shown in collection views" — this is inaccurate based on code review. It renders on every page (it's in `__root.tsx` line 159 as `<TotalsBar />`).
**How to avoid:** No changes to TotalsBar are needed — it already correctly shows "Sign in" for anonymous users. This is already done. The D-10 requirement is satisfied by existing code.

### Pitfall 5: `window.location.href` vs Router Navigate
**What goes wrong:** Removing the hard redirect to `/login` but forgetting to add a soft redirect for genuinely private routes (`/collection`, `/settings`, `/threads`) means those pages render for anonymous users.
**Why it happens:** The redirect removal is necessary for public routes, but private routes still need protection.
**How to avoid:** Replace `window.location.href = "/login"` with TanStack Router `navigate({ to: "/login" })` scoped only to private routes, and only invoked after `authLoading` is false (not during the loading state).

---

## Code Examples

### Rate Limit Factory
```typescript
// src/server/middleware/rateLimit.ts
// Source: based on existing implementation in this file

const store = new Map<string, RateLimitEntry>();

export function createRateLimit(maxAttempts: number, windowMs: number) {
  return async function(c: Context, next: Next) {
    cleanup();
    const ip = getClientIp(c);
    const key = `${ip}:${c.req.path}`;
    const now = Date.now();
    const entry = store.get(key);

    if (!entry || now >= entry.resetAt) {
      store.set(key, { count: 1, resetAt: now + windowMs });
      return next();
    }
    if (entry.count >= maxAttempts) {
      const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
      c.header("Retry-After", String(retryAfter));
      return c.json({ error: "Too many requests. Try again later." }, 429);
    }
    entry.count++;
    return next();
  };
}

// Backward-compatible export for existing OAuth usage
export async function rateLimit(c: Context, next: Next) {
  return createRateLimit(5, 15 * 60 * 1000)(c, next);
}

export function _resetForTesting() {
  store.clear();
}
```

### Root Layout Auth Fix (key diff)
```typescript
// src/client/routes/__root.tsx — key changes

// REMOVE: full-page spinner on authLoading
// REMOVE: window.location.href = "/login"

// REPLACE isPublicRoute with:
const isPublicRoute =
  location.pathname === "/" ||
  location.pathname.startsWith("/users/") ||
  location.pathname.startsWith("/global-items") ||
  location.pathname.startsWith("/setups/") ||
  location.pathname === "/login";

// REPLACE hard redirect with soft redirect for private routes only:
if (!isAuthenticated && !isPublicRoute && !authLoading) {
  navigate({ to: "/login" });
  return null;
}

// REMOVE: onboarding spinner gate for unauthenticated users
// (showWizard already guards on isAuthenticated — just remove the separate loading gate)
```

### usePublicSetup Hook (new)
```typescript
// src/client/hooks/useSetups.ts — add:
export function usePublicSetup(id: number) {
  return useQuery({
    queryKey: ["setups", id, "public"],
    queryFn: () => apiGet<PublicSetup>(`/api/setups/${id}/public`),
  });
}
```

---

## State of the Art

| Old Approach | Current Approach | Impact |
|--------------|------------------|--------|
| Auth spinner then redirect (current) | Render immediately, soft redirect only for private routes | PUBL-04 requirement |
| Hard `window.location.href` redirect | TanStack Router `navigate()` | No full page reload, better UX |
| Single rate limit tier (5/15min) | Factory with configurable tiers | Enables appropriate limits per endpoint type |

---

## Open Questions

1. **`/setups/$setupId` anonymous route — separate page or conditional rendering?**
   - What we know: The current setup detail page (`setups/$setupId.tsx`) has heavy write actions (Add Items, Delete, Public toggle) that make no sense for anonymous visitors.
   - What's unclear: Whether to build a completely separate read-only public setup page at a new route (e.g., `/setups/$setupId/view`) or gate the existing page's write actions on `isAuthenticated`.
   - Recommendation: Keep the single route `/setups/$setupId`. Detect `isAuthenticated`, call the correct API endpoint, and conditionally render write action sections. This is lower scope and matches D-04's intent of "expand public routes" rather than "add new routes."

2. **Rate limit tier for tag browse endpoint?**
   - What we know: GET `/api/tags` is already public; used for filtering in the catalog.
   - Recommendation: Apply `browseTier` (120 req/min). Tags are lightweight and unlikely to be abused separately from global items.

---

## Environment Availability

Step 2.6: SKIPPED — Phase 24 is code-only changes. No external services or CLI tools beyond the existing Bun/Node runtime are introduced. Rate limiting uses the existing in-memory Map. No new database migrations required.

---

## Validation Architecture

### Test Framework

| Property | Value |
|----------|-------|
| Framework | Bun test (built-in) |
| Config file | `bunfig.toml` (if present) or none — `bun test` discovers `tests/**/*.test.ts` |
| Quick run command | `bun test tests/middleware/rateLimit.test.ts tests/routes/global-items.test.ts tests/routes/profiles.test.ts` |
| Full suite command | `bun test` |

### Phase Requirements → Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| PUBL-01 | GET `/api/global-items` returns 200 without auth | unit | `bun test tests/routes/global-items.test.ts` | YES |
| PUBL-02 | GET `/api/setups/:id/public` returns 200 without auth | unit | `bun test tests/routes/profiles.test.ts` (contains public setup tests) | YES |
| PUBL-03 | GET `/api/users/:id/profile` returns 200 without auth | unit | `bun test tests/routes/profiles.test.ts` | YES |
| PUBL-04 | Root layout renders without spinner for anonymous visitor | e2e / manual | `bun run test:e2e` + manual visual check | Wave 0 — e2e test needed |
| PUBL-05 | Write actions intercepted for anonymous users | e2e / manual | `bun run test:e2e` — visit catalog, click "Add to Collection" unauthed | Wave 0 — e2e test needed |
| INFR-01 | Rate limit returns 429 after limit exceeded on public endpoints | unit | `bun test tests/middleware/rateLimit.test.ts` | Partially YES — existing tests cover old API; new tests needed for factory tiers |

### Sampling Rate

- **Per task commit:** `bun test tests/middleware/rateLimit.test.ts tests/routes/global-items.test.ts tests/routes/profiles.test.ts`
- **Per wave merge:** `bun test`
- **Phase gate:** Full suite green before `/gsd:verify-work`

### Wave 0 Gaps

- [ ] `tests/middleware/rateLimit.test.ts` — extend with `createRateLimit` factory tests (configurable max/window)
- [ ] `e2e/public-access.spec.ts` — covers PUBL-04 (no spinner on load) and PUBL-05 (auth prompt on write action)

*(Existing test infrastructure covers PUBL-01 through PUBL-03 and INFR-01 base cases.)*

---

## Sources

### Primary (HIGH confidence)
- Direct code inspection: `src/server/index.ts` — verified existing public route allowlist (lines 121-140)
- Direct code inspection: `src/client/routes/__root.tsx` — verified `authLoading` spinner gate and `window.location.href` hard redirect
- Direct code inspection: `src/server/middleware/rateLimit.ts` — verified current single-tier implementation
- Direct code inspection: `src/client/components/TotalsBar.tsx` — verified "Sign in" link already present for anonymous users
- Direct code inspection: `src/client/hooks/useAuth.ts` — verified React Query `useQuery` with `retry: false`
- Direct code inspection: `tests/middleware/rateLimit.test.ts`, `tests/routes/profiles.test.ts`, `tests/routes/global-items.test.ts` — verified existing test coverage
- Direct code inspection: `package.json` — verified TanStack Router ^1.167.0, React Query ^5.90.21, Hono ^4.12.8

### Secondary (MEDIUM confidence)
- None required — all findings are from direct source code inspection

---

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — all libraries already in project, verified versions
- Architecture patterns: HIGH — based on direct code reading of files to be modified
- Pitfalls: HIGH — each pitfall identified from actual code behavior verified in source files
- Rate limit tier values: MEDIUM — reasonable defaults per D-08 discretion; expect tuning

**Research date:** 2026-04-10
**Valid until:** 2026-05-10 (stable stack, no fast-moving dependencies)
