diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 989c3ce..01db5bd 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -64,7 +64,7 @@ -### 🚧 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 | - | diff --git a/.planning/phases/24-public-access-infrastructure/24-01-PLAN.md b/.planning/phases/24-public-access-infrastructure/24-01-PLAN.md new file mode 100644 index 0000000..7ed43ec --- /dev/null +++ b/.planning/phases/24-public-access-infrastructure/24-01-PLAN.md @@ -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+," +--- + + +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + + + + Task 1: Refactor rateLimit.ts to factory pattern and extend tests + src/server/middleware/rateLimit.ts, tests/middleware/rateLimit.test.ts + + - src/server/middleware/rateLimit.ts (current single-tier implementation) + - tests/middleware/rateLimit.test.ts (existing tests to preserve) + + + - 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 + + + 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. + + + cd /home/jean-luc-makiola/Development/projects/GearBox && bun test tests/middleware/rateLimit.test.ts + + + - 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 + + createRateLimit factory exported, backward-compatible rateLimit still works, all tests pass including new factory tests + + + + Task 2: Apply tiered rate limits to public GET endpoints in index.ts + src/server/index.ts + + - 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) + + + 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. + + + cd /home/jean-luc-makiola/Development/projects/GearBox && bun test tests/middleware/rateLimit.test.ts && bun run lint + + + - 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 + + 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. + + + + + +- `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) + + + +- 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 + + + +After completion, create `.planning/phases/24-public-access-infrastructure/24-01-SUMMARY.md` + diff --git a/.planning/phases/24-public-access-infrastructure/24-02-PLAN.md b/.planning/phases/24-public-access-infrastructure/24-02-PLAN.md new file mode 100644 index 0000000..1e9a045 --- /dev/null +++ b/.planning/phases/24-public-access-infrastructure/24-02-PLAN.md @@ -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: " +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + + +From src/client/hooks/useAuth.ts: +```typescript +interface AuthState { + user: { id: string; email?: string } | null; + authenticated: boolean; +} +export function useAuth(): UseQueryResult; +``` + +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; +// New hook to add: +export function usePublicSetup(id: number): UseQueryResult; +``` + +From src/client/components/TotalsBar.tsx: +```typescript +// Already handles Sign in vs UserMenu — NO changes needed (D-05, D-10 satisfied) +``` + + + + + + + Task 1: Add auth prompt state to uiStore, create AuthPromptModal, add usePublicSetup hook + src/client/stores/uiStore.ts, src/client/components/AuthPromptModal.tsx, src/client/hooks/useSetups.ts, src/client/hooks/useSettings.ts + + - 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) + + + **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 ( +
+
{ + if (e.key === "Escape") closeAuthPrompt(); + }} + /> +
+

+ Join GearBox +

+

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

+
+ + Sign in + + + Create account + +
+
+
+ ); + } + ``` + + 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(`/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(`/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. + + + cd /home/jean-luc-makiola/Development/projects/GearBox && bun run lint + + + - 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 + + Auth prompt modal component created, uiStore extended, usePublicSetup hook added, useOnboardingComplete accepts enabled flag + + + + Task 2: Rework __root.tsx for render-first, guard write actions on catalog and setup pages + src/client/routes/__root.tsx, src/client/routes/global-items/$globalItemId.tsx, src/client/routes/setups/$setupId.tsx + + - 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) + + + **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 ( +
+
+
+ ); + } + ``` + + **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 `
`, after the `` and before the onboarding wizard: + ```tsx + {/* Auth Prompt Modal */} + + ``` + + **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 && ( + + )} + ``` + + 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). + + + cd /home/jean-luc-makiola/Development/projects/GearBox && bun run lint + + + - __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 ` + 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. + + + + Task 3: Verify public access flows + + 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. + + + 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) + + Type "approved" or describe issues + + + + + +- 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) + + + +- 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 + + + +After completion, create `.planning/phases/24-public-access-infrastructure/24-02-SUMMARY.md` +