# 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 (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. --- ## 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` | --- ## 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:

Sign in to manage your collection

To manage your own collection, sign in or sign up.

Sign in Create account {/* 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 ``). **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(); 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(`/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)