docs(24): create phase plan

This commit is contained in:
2026-04-10 10:02:35 +02:00
parent 2a8a479012
commit 08ff7d59bf
3 changed files with 699 additions and 3 deletions

View File

@@ -64,7 +64,7 @@
</details>
### 🚧 v2.1 Public Discovery (In Progress)
### v2.1 Public Discovery (In Progress)
**Milestone Goal:** Transform GearBox from a login-first tool into a public-first discovery platform with always-on catalog search and a browsable feed of community content.
@@ -84,7 +84,12 @@
3. An unauthenticated visitor can view a public setup and see its items and totals
4. An unauthenticated visitor can view a user's public profile page
5. Attempting to create, edit, or delete any item/setup/thread while unauthenticated redirects to login
**Plans**: TBD
**Plans**: 2 plans
Plans:
- [ ] 24-01-PLAN.md — Rate limit factory and tiered public endpoint protection
- [ ] 24-02-PLAN.md — Client-side public access (render-first root, auth prompt, setup/catalog guards)
**UI hint**: yes
### Phase 25: Catalog Enrichment & Agent Tools
@@ -139,7 +144,7 @@
| 21. Item & Catalog Detail Pages | v2.0 | 3/3 | Complete | 2026-04-06 |
| 22. Add-from-Catalog & Thread Integration | v2.0 | 2/2 | Complete | 2026-04-06 |
| 23. Manual Entry Fallback | v2.0 | 1/1 | Complete | 2026-04-06 |
| 24. Public Access & Infrastructure | v2.1 | 0/TBD | Not started | - |
| 24. Public Access & Infrastructure | v2.1 | 0/2 | In progress | - |
| 25. Catalog Enrichment & Agent Tools | v2.1 | 0/TBD | Not started | - |
| 26. Discovery Landing Page | v2.1 | 0/TBD | Not started | - |

View File

@@ -0,0 +1,216 @@
---
phase: 24-public-access-infrastructure
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- src/server/middleware/rateLimit.ts
- src/server/index.ts
- tests/middleware/rateLimit.test.ts
autonomous: true
requirements: [INFR-01]
must_haves:
truths:
- "Public GET endpoints return 429 after exceeding the configured rate limit"
- "Different endpoint tiers have different rate limit thresholds"
- "Existing OAuth rate limiting (5 req/15 min) continues to work unchanged"
artifacts:
- path: "src/server/middleware/rateLimit.ts"
provides: "createRateLimit factory function"
exports: ["createRateLimit", "rateLimit", "_resetForTesting"]
- path: "src/server/index.ts"
provides: "Rate limit middleware applied to public GET endpoints"
contains: "createRateLimit"
- path: "tests/middleware/rateLimit.test.ts"
provides: "Tests for configurable rate limit tiers"
contains: "createRateLimit"
key_links:
- from: "src/server/index.ts"
to: "src/server/middleware/rateLimit.ts"
via: "import createRateLimit"
pattern: "createRateLimit\\(\\d+,"
---
<objective>
Refactor the rate limiter into a configurable factory and apply tiered rate limits to all public GET API endpoints.
Purpose: Protect public endpoints from abuse (INFR-01) while allowing normal browsing patterns. The existing single-tier (5 req/15 min) rate limiter is only appropriate for OAuth/auth endpoints.
Output: `createRateLimit(max, windowMs)` factory, tiered limits on public GET routes, extended tests.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/24-public-access-infrastructure/24-CONTEXT.md
@.planning/phases/24-public-access-infrastructure/24-RESEARCH.md
@src/server/middleware/rateLimit.ts
@src/server/index.ts
@tests/middleware/rateLimit.test.ts
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Refactor rateLimit.ts to factory pattern and extend tests</name>
<files>src/server/middleware/rateLimit.ts, tests/middleware/rateLimit.test.ts</files>
<read_first>
- src/server/middleware/rateLimit.ts (current single-tier implementation)
- tests/middleware/rateLimit.test.ts (existing tests to preserve)
</read_first>
<behavior>
- Test: createRateLimit(3, 60000) allows exactly 3 requests then returns 429
- Test: createRateLimit(10, 60000) allows exactly 10 requests then returns 429
- Test: Two different createRateLimit instances with different limits operate independently (share store but different keys)
- Test: Original `rateLimit` export still blocks after 5 requests (backward compat)
- Test: 429 response includes Retry-After header
- Test: Different IPs tracked independently with createRateLimit
</behavior>
<action>
Refactor `src/server/middleware/rateLimit.ts` per D-07:
1. Keep the existing module-level `store` Map and `cleanup()`, `getClientIp()` helper functions unchanged.
2. Add a new exported factory function:
```typescript
export function createRateLimit(maxAttempts: number, windowMs: number) {
return async function rateLimitMiddleware(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();
};
}
```
3. Rewrite the original `rateLimit` export to delegate to the factory:
```typescript
export const rateLimit = createRateLimit(5, 15 * 60 * 1000);
```
Note: Change from `async function` to `const` assignment. The `rateLimit` export must remain a middleware function (not a wrapper that creates one on each call).
4. Keep `_resetForTesting()` unchanged — it clears the shared store, which is correct for all tiers.
In `tests/middleware/rateLimit.test.ts`:
5. Add import for `createRateLimit` alongside existing imports.
6. Add a new `describe("createRateLimit factory")` block with tests for:
- Custom limit (3 req) blocks on 4th request
- Custom limit (10 req) allows 10 then blocks
- Different IPs tracked independently
- Retry-After header present on 429
7. Keep all existing tests in the `"rateLimit middleware"` describe block unchanged — they validate backward compatibility.
</action>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/GearBox && bun test tests/middleware/rateLimit.test.ts</automated>
</verify>
<acceptance_criteria>
- rateLimit.ts contains `export function createRateLimit(maxAttempts: number, windowMs: number)`
- rateLimit.ts contains `export const rateLimit = createRateLimit(5,` (backward-compatible export)
- rateLimit.ts contains `export function _resetForTesting()`
- rateLimit.test.ts contains `describe("createRateLimit factory"` with at least 4 test cases
- All existing tests in "rateLimit middleware" describe block still pass
- `bun test tests/middleware/rateLimit.test.ts` exits 0
</acceptance_criteria>
<done>createRateLimit factory exported, backward-compatible rateLimit still works, all tests pass including new factory tests</done>
</task>
<task type="auto">
<name>Task 2: Apply tiered rate limits to public GET endpoints in index.ts</name>
<files>src/server/index.ts</files>
<read_first>
- src/server/index.ts (current route registration and auth skip logic, lines 100-167)
- src/server/middleware/rateLimit.ts (after Task 1 — confirm createRateLimit export exists)
</read_first>
<action>
Apply rate limit tiers to public GET endpoints per D-07 and D-08 (same limits for auth and anon).
1. Add import at top of `src/server/index.ts`:
```typescript
import { createRateLimit } from "./middleware/rateLimit";
```
2. After the `app.use("/api/*", async (c, next) => { c.set("db", prodDb); ... })` block (around line 118) and BEFORE the auth middleware block (line 121), add rate limit middleware:
```typescript
// Rate limiting for public endpoints (per D-07, D-08)
const browseTier = createRateLimit(120, 60_000);
const detailTier = createRateLimit(60, 60_000);
// Browse endpoints — higher limit for list/search
app.use("/api/global-items", async (c, next) => {
if (c.req.method === "GET" && !c.req.path.match(/^\/api\/global-items\/\d+$/))
return browseTier(c, next);
return next();
});
app.use("/api/tags", async (c, next) => {
if (c.req.method === "GET") return browseTier(c, next);
return next();
});
// Detail endpoints — moderate limit for individual resources
app.use("/api/global-items/:id", async (c, next) => {
if (c.req.method === "GET") return detailTier(c, next);
return next();
});
app.use("/api/setups/:id/public", async (c, next) => {
if (c.req.method === "GET") return detailTier(c, next);
return next();
});
app.use("/api/users/:id/profile", async (c, next) => {
if (c.req.method === "GET") return detailTier(c, next);
return next();
});
```
3. Do NOT modify the existing auth skip logic (lines 121-140) — it already correctly skips auth for these GET endpoints per D-01.
4. Do NOT apply rate limits to `/api/auth/*` or OAuth endpoints — those already have the original `rateLimit` (5/15min) applied where needed.
</action>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/GearBox && bun test tests/middleware/rateLimit.test.ts && bun run lint</automated>
</verify>
<acceptance_criteria>
- index.ts contains `import { createRateLimit } from "./middleware/rateLimit"`
- index.ts contains `const browseTier = createRateLimit(120, 60_000)`
- index.ts contains `const detailTier = createRateLimit(60, 60_000)`
- index.ts contains rate limit middleware for `/api/global-items`, `/api/tags`, `/api/global-items/:id`, `/api/setups/:id/public`, `/api/users/:id/profile`
- Rate limit middleware is placed BEFORE the auth middleware block
- `bun run lint` exits 0
</acceptance_criteria>
<done>All public GET endpoints have tiered rate limits applied. Browse endpoints (global-items list, tags) at 120/min, detail endpoints (global-item detail, public setup, profile) at 60/min.</done>
</task>
</tasks>
<verification>
- `bun test tests/middleware/rateLimit.test.ts` — all rate limit tests pass
- `bun run lint` — no lint errors
- `bun test` — full suite passes (no regressions)
</verification>
<success_criteria>
- createRateLimit factory is exported and tested with configurable limits
- Original rateLimit export unchanged in behavior (backward compatible)
- All 5 public GET endpoint groups have rate limits applied in index.ts
- Rate limits are applied before auth middleware
- No new dependencies added
</success_criteria>
<output>
After completion, create `.planning/phases/24-public-access-infrastructure/24-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,475 @@
---
phase: 24-public-access-infrastructure
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
- src/client/routes/__root.tsx
- src/client/stores/uiStore.ts
- src/client/components/AuthPromptModal.tsx
- src/client/hooks/useSetups.ts
- src/client/hooks/useSettings.ts
- src/client/routes/global-items/$globalItemId.tsx
- src/client/routes/setups/$setupId.tsx
autonomous: false
requirements: [PUBL-01, PUBL-02, PUBL-03, PUBL-04, PUBL-05]
must_haves:
truths:
- "Anonymous visitor sees app content immediately on any public route — no spinner, no redirect"
- "Anonymous visitor can browse the global item catalog and open catalog detail pages"
- "Anonymous visitor can view a public setup with its items and totals"
- "Anonymous visitor can view a user profile page"
- "Anonymous visitor clicking 'Add to Collection' or 'Add to Thread' sees a sign-in/sign-up prompt instead of the action"
- "Authenticated user experience is unchanged — all write actions work as before"
artifacts:
- path: "src/client/routes/__root.tsx"
provides: "Render-first root layout with expanded isPublicRoute"
contains: "pathname.startsWith(\"/global-items\")"
- path: "src/client/stores/uiStore.ts"
provides: "showAuthPrompt state for auth modal"
contains: "showAuthPrompt"
- path: "src/client/components/AuthPromptModal.tsx"
provides: "Modal prompting anonymous users to sign in or sign up"
contains: "sign in or sign up"
- path: "src/client/hooks/useSetups.ts"
provides: "usePublicSetup hook for anonymous setup viewing"
exports: ["usePublicSetup"]
- path: "src/client/routes/global-items/$globalItemId.tsx"
provides: "Auth-guarded write action buttons on catalog detail"
contains: "openAuthPrompt"
- path: "src/client/routes/setups/$setupId.tsx"
provides: "Conditional public vs private setup rendering"
contains: "usePublicSetup"
key_links:
- from: "src/client/routes/__root.tsx"
to: "src/client/components/AuthPromptModal.tsx"
via: "rendered in root layout"
pattern: "<AuthPromptModal"
- from: "src/client/routes/global-items/$globalItemId.tsx"
to: "src/client/stores/uiStore.ts"
via: "openAuthPrompt action"
pattern: "openAuthPrompt"
- from: "src/client/routes/setups/$setupId.tsx"
to: "src/client/hooks/useSetups.ts"
via: "usePublicSetup hook"
pattern: "usePublicSetup"
---
<objective>
Make the app render immediately for anonymous visitors, expand public route access to catalog and setups, and intercept write actions with a friendly auth prompt.
Purpose: Transform GearBox from a login-first tool into a public-first browsing experience (PUBL-01 through PUBL-05). Anonymous visitors see content instantly; write actions prompt sign-in/sign-up instead of hard-redirecting.
Output: Reworked root layout, auth prompt modal, public setup hook, guarded write actions.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/24-public-access-infrastructure/24-CONTEXT.md
@.planning/phases/24-public-access-infrastructure/24-RESEARCH.md
@src/client/routes/__root.tsx
@src/client/stores/uiStore.ts
@src/client/hooks/useSetups.ts
@src/client/hooks/useAuth.ts
@src/client/hooks/useSettings.ts
@src/client/routes/global-items/$globalItemId.tsx
@src/client/routes/setups/$setupId.tsx
@src/client/components/TotalsBar.tsx
<interfaces>
<!-- Key types and contracts the executor needs. -->
From src/client/hooks/useAuth.ts:
```typescript
interface AuthState {
user: { id: string; email?: string } | null;
authenticated: boolean;
}
export function useAuth(): UseQueryResult<AuthState>;
```
From src/client/stores/uiStore.ts:
```typescript
// Existing pattern — all boolean state with open/close actions
showAuthPrompt: boolean;
openAuthPrompt: () => void;
closeAuthPrompt: () => void;
```
From src/client/hooks/useSetups.ts:
```typescript
export function useSetup(setupId: number | null): UseQueryResult<SetupWithItems>;
// New hook to add:
export function usePublicSetup(id: number): UseQueryResult<PublicSetupData>;
```
From src/client/components/TotalsBar.tsx:
```typescript
// Already handles Sign in vs UserMenu — NO changes needed (D-05, D-10 satisfied)
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add auth prompt state to uiStore, create AuthPromptModal, add usePublicSetup hook</name>
<files>src/client/stores/uiStore.ts, src/client/components/AuthPromptModal.tsx, src/client/hooks/useSetups.ts, src/client/hooks/useSettings.ts</files>
<read_first>
- src/client/stores/uiStore.ts (current state shape and patterns)
- src/client/hooks/useSetups.ts (existing hooks, types)
- src/client/hooks/useSettings.ts (useOnboardingComplete — needs `enabled` guard)
- src/client/routes/__root.tsx (CandidateDeleteDialog pattern for modal structure)
- src/client/components/TotalsBar.tsx (confirm D-10 already handled)
</read_first>
<action>
**1. Extend uiStore.ts** — Add auth prompt state following the existing pattern (e.g., `externalLinkUrl`):
Add to the `UIState` interface:
```typescript
// Auth prompt modal
showAuthPrompt: boolean;
openAuthPrompt: () => void;
closeAuthPrompt: () => void;
```
Add to the `create` implementation:
```typescript
// Auth prompt modal
showAuthPrompt: false,
openAuthPrompt: () => set({ showAuthPrompt: true }),
closeAuthPrompt: () => set({ showAuthPrompt: false }),
```
**2. Create AuthPromptModal.tsx** per D-06 — inline popup/modal with "sign in or sign up" language. Follow the exact pattern of CandidateDeleteDialog in `__root.tsx` (fixed overlay, centered card, bg-black/30 backdrop):
```typescript
import { Link } from "@tanstack/react-router";
import { useUIStore } from "../stores/uiStore";
export function AuthPromptModal() {
const showAuthPrompt = useUIStore((s) => s.showAuthPrompt);
const closeAuthPrompt = useUIStore((s) => s.closeAuthPrompt);
if (!showAuthPrompt) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/30"
onClick={closeAuthPrompt}
onKeyDown={(e) => {
if (e.key === "Escape") closeAuthPrompt();
}}
/>
<div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Join GearBox
</h3>
<p className="text-sm text-gray-600 mb-6">
To manage your own collection, sign in or sign up.
</p>
<div className="flex flex-col gap-3">
<Link
to="/login"
className="w-full text-center px-4 py-2.5 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors"
onClick={closeAuthPrompt}
>
Sign in
</Link>
<Link
to="/login"
className="w-full text-center px-4 py-2.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
onClick={closeAuthPrompt}
>
Create account
</Link>
</div>
</div>
</div>
);
}
```
Both links go to `/login` because Logto handles both sign-in and sign-up at the same OIDC redirect. The UX distinction is in the button labels per the user's emphasis on welcoming new users (from specifics in CONTEXT.md).
**3. Add usePublicSetup hook** in `src/client/hooks/useSetups.ts`:
Add after the existing `useSetup` function:
```typescript
export function usePublicSetup(setupId: number | null) {
return useQuery({
queryKey: ["setups", setupId, "public"],
queryFn: () => apiGet<SetupWithItems>(`/api/setups/${setupId}/public`),
enabled: setupId != null,
retry: (count, error) =>
error instanceof ApiError && error.status === 404 ? false : count < 3,
});
}
```
The public endpoint returns the same shape as the private one (SetupWithItems) but with `isPublic` always `true` and the owner's category names included as read-only context per D-03.
**4. Guard useOnboardingComplete** in `src/client/hooks/useSettings.ts` — Pitfall 2 from research. The `useSetting` hook calls an auth-gated endpoint. For unauthenticated users, it returns an error and `isLoading` may be `true` briefly, blocking render.
Change `useOnboardingComplete` to accept an `enabled` parameter:
```typescript
export function useOnboardingComplete(enabled = true) {
return useQuery({
queryKey: ["settings", "onboardingComplete"],
queryFn: async () => {
try {
const result = await apiGet<Setting>(`/api/settings/onboardingComplete`);
return result.value;
} catch (err: any) {
if (err?.status === 404) return null;
throw err;
}
},
enabled,
});
}
```
This replaces the current delegation to `useSetting("onboardingComplete")` with a direct `useQuery` call that accepts an `enabled` parameter. The query logic is identical to `useSetting` — just inlined so `enabled` can be passed through.
</action>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/GearBox && bun run lint</automated>
</verify>
<acceptance_criteria>
- uiStore.ts contains `showAuthPrompt: boolean` in the interface
- uiStore.ts contains `openAuthPrompt: () => set({ showAuthPrompt: true })`
- uiStore.ts contains `closeAuthPrompt: () => set({ showAuthPrompt: false })`
- AuthPromptModal.tsx exists and contains `sign in or sign up`
- AuthPromptModal.tsx contains `to="/login"` (both links point to /login)
- AuthPromptModal.tsx contains `Create account` button text
- AuthPromptModal.tsx contains `className="fixed inset-0 z-50`
- useSetups.ts contains `export function usePublicSetup(`
- useSetups.ts contains `/api/setups/${setupId}/public`
- useSettings.ts `useOnboardingComplete` accepts `enabled` parameter
- `bun run lint` exits 0
</acceptance_criteria>
<done>Auth prompt modal component created, uiStore extended, usePublicSetup hook added, useOnboardingComplete accepts enabled flag</done>
</task>
<task type="auto">
<name>Task 2: Rework __root.tsx for render-first, guard write actions on catalog and setup pages</name>
<files>src/client/routes/__root.tsx, src/client/routes/global-items/$globalItemId.tsx, src/client/routes/setups/$setupId.tsx</files>
<read_first>
- src/client/routes/__root.tsx (current auth loading spinner, redirect logic, isPublicRoute)
- src/client/routes/global-items/$globalItemId.tsx (action buttons to guard)
- src/client/routes/setups/$setupId.tsx (full file — need to understand write actions and data flow)
- src/client/stores/uiStore.ts (after Task 1 — confirm showAuthPrompt exists)
- src/client/components/AuthPromptModal.tsx (after Task 1 — confirm component exists)
- src/client/hooks/useSetups.ts (after Task 1 — confirm usePublicSetup exists)
</read_first>
<action>
**1. Rework __root.tsx** per D-04 and D-09:
**a. Remove the authLoading spinner gate (lines 121-127).** Delete this entire block:
```typescript
// REMOVE THIS:
if (authLoading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-gray-600 border-t-transparent rounded-full animate-spin" />
</div>
);
}
```
**b. Expand isPublicRoute** (replace current line 131-132):
```typescript
const isPublicRoute =
location.pathname === "/" ||
location.pathname.startsWith("/users/") ||
location.pathname.startsWith("/global-items") ||
location.pathname.startsWith("/setups/") ||
location.pathname === "/login";
```
**c. Replace hard redirect** (replace lines 138-145). Remove `window.location.href = "/login"` and replace with soft redirect that only fires after auth resolves:
```typescript
if (!isAuthenticated && !isPublicRoute && !authLoading) {
navigate({ to: "/login" });
return null;
}
```
**d. Remove onboarding loading spinner gate** (lines 147-154). Delete the entire `if (onboardingLoading)` block. The `showWizard` check already guards on `isAuthenticated`, so this gate is unnecessary. Update the `useOnboardingComplete` call to pass `enabled: isAuthenticated`:
```typescript
const { data: onboardingComplete, isLoading: onboardingLoading } =
useOnboardingComplete(isAuthenticated);
```
**e. Add AuthPromptModal** to the return JSX. Import at top:
```typescript
import { AuthPromptModal } from "../components/AuthPromptModal";
```
Add inside the root `<div>`, after the `<Toaster>` and before the onboarding wizard:
```tsx
{/* Auth Prompt Modal */}
<AuthPromptModal />
```
**2. Guard write actions in global-items/$globalItemId.tsx** per D-06 and PUBL-05:
Add imports:
```typescript
import { useAuth } from "../../hooks/useAuth";
```
Inside the `GlobalItemDetail` component, add:
```typescript
const { data: auth } = useAuth();
const isAuthenticated = !!auth?.user;
const openAuthPrompt = useUIStore((s) => s.openAuthPrompt);
```
Replace the two button onClick handlers. For "Add to Collection":
```typescript
onClick={() => {
if (!isAuthenticated) {
openAuthPrompt();
return;
}
openAddToCollection(item.id, `${item.brand} ${item.model}`);
}}
```
For "Add to Thread":
```typescript
onClick={() => {
if (!isAuthenticated) {
openAuthPrompt();
return;
}
openAddToThread(item.id, `${item.brand} ${item.model}`);
}}
```
**3. Rework setups/$setupId.tsx** for anonymous viewing per PUBL-02:
This is the most complex change. The current page calls `useSetup(id)` which hits the auth-gated `GET /api/setups/:id`. Anonymous visitors get a 401.
Add imports:
```typescript
import { useAuth } from "../../hooks/useAuth";
import { usePublicSetup } from "../../hooks/useSetups";
```
At the top of `SetupDetailPage`, add auth detection:
```typescript
const { data: auth } = useAuth();
const isAuthenticated = !!auth?.user;
```
Change the data fetching to be conditional:
```typescript
const privateSetup = useSetup(isAuthenticated ? numericId : null);
const publicSetup = usePublicSetup(!isAuthenticated ? numericId : null);
const { data: setup, isLoading } = isAuthenticated
? privateSetup
: publicSetup;
```
Wrap all write action UI elements (Delete button, Add Items button, Public toggle, remove item buttons, classification dropdowns) in `isAuthenticated` guards:
```typescript
{isAuthenticated && (
<button onClick={() => setPickerOpen(true)}>Add Items</button>
)}
```
Apply this guard to:
- The "Add Items" button
- The "Delete Setup" button and its confirmation dialog
- The "Public" toggle switch
- The remove button on individual items (the X icon)
- The classification dropdown on individual items
- The `ItemPicker` component render
The read-only display (setup name, items list, weight summary, totals) should render for everyone.
Also: the mutation hooks (`useDeleteSetup`, `useUpdateSetup`, `useRemoveSetupItem`, `useUpdateItemClassification`) can remain — they just won't be invoked since their triggers are hidden. But `useDeleteSetup()` and `useUpdateSetup(numericId)` calls at the top of the component are fine to keep (they return mutation objects, no network call until `.mutate()` is called).
</action>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/GearBox && bun run lint</automated>
</verify>
<acceptance_criteria>
- __root.tsx does NOT contain `if (authLoading)` followed by a spinner return
- __root.tsx does NOT contain `window.location.href = "/login"`
- __root.tsx contains `pathname === "/" ||` in isPublicRoute
- __root.tsx contains `pathname.startsWith("/global-items")` in isPublicRoute
- __root.tsx contains `pathname.startsWith("/setups/")` in isPublicRoute
- __root.tsx contains `navigate({ to: "/login" })` (soft redirect for private routes)
- __root.tsx contains `!authLoading` in the redirect condition
- __root.tsx contains `<AuthPromptModal` in the JSX
- __root.tsx contains `useOnboardingComplete(isAuthenticated)`
- global-items/$globalItemId.tsx contains `openAuthPrompt` import from uiStore
- global-items/$globalItemId.tsx contains `if (!isAuthenticated)` before openAddToCollection
- global-items/$globalItemId.tsx contains `if (!isAuthenticated)` before openAddToThread
- setups/$setupId.tsx contains `usePublicSetup` import
- setups/$setupId.tsx contains `useAuth` import
- setups/$setupId.tsx contains conditional `isAuthenticated ? privateSetup : publicSetup` or equivalent
- setups/$setupId.tsx write action buttons wrapped in `{isAuthenticated &&` guards
- `bun run lint` exits 0
</acceptance_criteria>
<done>Root layout renders immediately for anonymous visitors. Public routes include /, /global-items/*, /setups/*, /users/*, /login. Write actions on catalog detail show auth prompt. Setup detail page shows read-only view for anonymous visitors using the public API endpoint.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Verify public access flows</name>
<what-built>
Complete public access infrastructure: anonymous visitors can browse catalog, view public setups, and view profiles without logging in. Write actions show a friendly sign-in/sign-up prompt instead of redirecting.
</what-built>
<how-to-verify>
1. Start dev server: `bun run dev`
2. Open an incognito/private browser window (no session)
3. Visit `http://localhost:5173/` — should see the app immediately (no spinner, no redirect to /login)
4. Visit `http://localhost:5173/global-items` — catalog page loads with items
5. Click on any catalog item — detail page loads with image, specs, action buttons
6. Click "Add to Collection" — auth prompt modal appears with "sign in or sign up" message, two buttons (Sign in, Create account)
7. Close the modal (click backdrop or press Escape)
8. Click "Add to Thread" — same auth prompt modal appears
9. Visit a public setup URL (e.g., `http://localhost:5173/setups/1` if a public setup exists) — setup renders with items and totals, no write action buttons visible
10. Visit a user profile (e.g., `http://localhost:5173/users/1`) — profile page loads
11. Verify top-right corner shows "Sign in" link (already existing in TotalsBar)
12. Now log in normally — verify all write actions work as before (FAB appears, Add to Collection works, setup edit buttons appear)
</how-to-verify>
<resume-signal>Type "approved" or describe issues</resume-signal>
</task>
</tasks>
<verification>
- Anonymous visitor can browse catalog without login (PUBL-01)
- Anonymous visitor can view public setups (PUBL-02)
- Anonymous visitor can view user profiles (PUBL-03)
- No auth spinner or redirect on first visit (PUBL-04)
- Write actions prompt sign-in instead of executing (PUBL-05)
- `bun run lint` passes
- `bun test` passes (no regressions)
</verification>
<success_criteria>
- Root layout renders immediately for anonymous visitors
- isPublicRoute includes /, /global-items/*, /setups/*, /users/*, /login
- AuthPromptModal shows friendly sign-in/sign-up prompt on write action attempts
- Setup detail page uses public API endpoint for anonymous visitors
- Catalog detail page guards both "Add to Collection" and "Add to Thread" buttons
- No hard redirects (window.location.href) remain in root layout
- Authenticated user experience is completely unchanged
</success_criteria>
<output>
After completion, create `.planning/phases/24-public-access-infrastructure/24-02-SUMMARY.md`
</output>