24 KiB
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
requireAuthas default middleware on/api/*. Expand the existing allowlist of public GET routes that skip auth (current pattern: regex checks insrc/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
isPublicRoutecheck in__root.tsxto 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/mein 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:
// 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:
// 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):
- Render spinner while
authLoading === true - After auth resolves: if unauthenticated AND not public route →
window.location.href = "/login"
Required flow (D-09):
- Render immediately — no
authLoadinggate - Auth resolves in background; UI updates silently (FAB appears, write buttons enable)
- If unauthenticated AND route is private (
/collection,/settings,/threads) → redirect to login
Key change in isPublicRoute:
// 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.
// In uiStore.ts — add:
showAuthPrompt: boolean;
openAuthPrompt: () => void;
closeAuthPrompt: () => void;
// 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:
// 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 withnavigate({ 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/123vs/api/global-items/456create separate buckets. For detail endpoints, this is fine (per-item limits). For list endpoints, the path is always/api/global-itemsso no issue. - Blocking render on auth: The existing
authLoadingreturn 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
// 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)
// 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)
// 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
-
/setups/$setupIdanonymous 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 onisAuthenticated. - Recommendation: Keep the single route
/setups/$setupId. DetectisAuthenticated, 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."
- What we know: The current setup detail page (
-
Rate limit tier for tag browse endpoint?
- What we know: GET
/api/tagsis 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.
- What we know: GET
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 withcreateRateLimitfactory 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— verifiedauthLoadingspinner gate andwindow.location.hrefhard 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 QueryuseQuerywithretry: 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)