---
phase: 24-public-access-infrastructure
verified: 2026-04-10T12:00:00Z
status: gaps_found
score: 5/6 must-haves verified
re_verification: false
gaps:
  - truth: "Anonymous visitor can view a public setup with its items and totals"
    status: partial
    reason: "Setup items display correctly but item images are missing for anonymous viewers. getPublicSetupWithItems does not call withImageUrls, so no presigned S3 URLs are generated. The $setupId.tsx component passes item.imageUrl (undefined) to ItemCard — confirmed TS2339 type error at line 284."
    artifacts:
      - path: "src/server/services/profile.service.ts"
        issue: "getPublicSetupWithItems (line 87) does not call withImageUrls on the returned item list, unlike the private getSetupWithItems in setup.service.ts"
      - path: "src/client/routes/setups/$setupId.tsx"
        issue: "Line 284: item.imageUrl is passed to ItemCard but SetupItemWithCategory only defines imageFilename. TypeScript error TS2339 confirms property does not exist on the type. Images silently not displayed for anonymous users."
    missing:
      - "Call withImageUrls on items in getPublicSetupWithItems, or add imageUrl to the service return type by enriching from storage service"
      - "Remove item.imageUrl reference from $setupId.tsx ItemCard props (or add imageUrl to SetupItemWithCategory after enrichment)"
human_verification:
  - test: "Anonymous visitor can view a public setup page"
    expected: "Setup renders with item list, weight totals, and cost totals. No Add Items / Delete / Public toggle buttons visible. Back arrow link works."
    why_human: "Visual confirmation of rendered output and write-action absence requires browser"
  - test: "Auth prompt modal behavior on catalog detail page"
    expected: "Clicking 'Add to Collection' or 'Add to Thread' shows the modal. Backdrop click closes it. Escape key closes it. Both buttons route to /login."
    why_human: "Modal interaction and keyboard events require browser verification"
  - test: "No auth spinner or redirect on first anonymous visit"
    expected: "App renders immediately at / and /global-items without redirect to /login or loading spinner"
    why_human: "Render timing and redirect behavior requires browser verification in an incognito session"
---

# Phase 24: Public Access Infrastructure Verification Report

**Phase Goal:** Anyone can browse the catalog, public setups, and user profiles without logging in
**Verified:** 2026-04-10T12:00:00Z
**Status:** gaps_found
**Re-verification:** No — initial verification

## Goal Achievement

### Observable Truths

| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | Public GET endpoints return 429 after exceeding the configured rate limit | VERIFIED | `createRateLimit` factory confirmed in rateLimit.ts:23; 11 tests pass |
| 2 | Different endpoint tiers have different rate limit thresholds | VERIFIED | browseTier(120, 60_000) and detailTier(60, 60_000) confirmed in index.ts:122-123 |
| 3 | Existing OAuth rate limiting (5 req/15 min) continues to work unchanged | VERIFIED | `rateLimit = createRateLimit(5, 15 * 60 * 1000)` at rateLimit.ts:44; backward-compat tests pass |
| 4 | Anonymous visitor sees app content immediately on any public route — no spinner, no redirect | VERIFIED | authLoading spinner block removed from __root.tsx; soft navigate() guard fires only after auth resolves and !authLoading |
| 5 | Anonymous visitor can browse the global item catalog and open catalog detail pages | VERIFIED | isPublicRoute includes pathname.startsWith("/global-items"); auth middleware skips GET /api/global-items; no auth required |
| 6 | Anonymous visitor can view a public setup with its items and totals | PARTIAL | Setup items and totals render correctly. Item images absent for anonymous viewers — getPublicSetupWithItems does not call withImageUrls; item.imageUrl is undefined (TS2339 at $setupId.tsx:284) |
| 7 | Anonymous visitor can view a user profile page | VERIFIED | isPublicRoute includes /users/; auth skips GET /api/users/:id/profile; getPublicProfile queries users + setups from DB |
| 8 | Anonymous visitor clicking 'Add to Collection' or 'Add to Thread' sees a sign-in/sign-up prompt | VERIFIED | openAuthPrompt() called before openAddToCollection/openAddToThread in $globalItemId.tsx:141-158; AuthPromptModal rendered globally in __root.tsx |
| 9 | Authenticated user experience is unchanged — all write actions work as before | VERIFIED | isAuthenticated guards all new branches; mutation hooks retained; private useSetup path unchanged |

**Score:** 8/9 truths verified (1 partial)

### Required Artifacts

| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `src/server/middleware/rateLimit.ts` | createRateLimit factory function | VERIFIED | exports createRateLimit, rateLimit, _resetForTesting; 49 lines, substantive |
| `src/server/index.ts` | Rate limit middleware applied to public GET endpoints | VERIFIED | browseTier and detailTier instantiated at lines 122-123; applied to 5 endpoint groups before auth middleware at line 151 |
| `tests/middleware/rateLimit.test.ts` | Tests for configurable rate limit tiers | VERIFIED | 181 lines; two describe blocks; createRateLimit factory with 5 tests; rateLimit backward compat with 6 tests; all 11 pass |
| `src/client/routes/__root.tsx` | Render-first root layout with expanded isPublicRoute | VERIFIED | No authLoading spinner; isPublicRoute includes /global-items and /setups/; AuthPromptModal rendered |
| `src/client/stores/uiStore.ts` | showAuthPrompt state for auth modal | VERIFIED | showAuthPrompt, openAuthPrompt, closeAuthPrompt in interface and implementation |
| `src/client/components/AuthPromptModal.tsx` | Modal prompting anonymous users to sign in or sign up | VERIFIED | Contains "sign in or sign up"; fixed overlay z-50; backdrop dismiss; two /login links |
| `src/client/hooks/useSetups.ts` | usePublicSetup hook for anonymous setup viewing | VERIFIED | usePublicSetup exported at line 67; calls /api/setups/${setupId}/public; enabled guard; 404-aware retry |
| `src/client/routes/global-items/$globalItemId.tsx` | Auth-guarded write action buttons on catalog detail | VERIFIED | openAuthPrompt imported and called in both button handlers with !isAuthenticated check |
| `src/client/routes/setups/$setupId.tsx` | Conditional public vs private setup rendering | PARTIAL | usePublicSetup imported and used; conditional data source correct; but item.imageUrl does not exist on SetupItemWithCategory type |

### Key Link Verification

| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `src/server/index.ts` | `src/server/middleware/rateLimit.ts` | import createRateLimit | WIRED | Line 13: `import { createRateLimit } from "./middleware/rateLimit.ts"`; pattern `createRateLimit(120,` confirmed at line 122 |
| `src/client/routes/__root.tsx` | `src/client/components/AuthPromptModal.tsx` | rendered in root layout | WIRED | Line 15: import; line 184: `<AuthPromptModal />` in JSX |
| `src/client/routes/global-items/$globalItemId.tsx` | `src/client/stores/uiStore.ts` | openAuthPrompt action | WIRED | Line 19: `const openAuthPrompt = useUIStore((s) => s.openAuthPrompt)`; called at lines 142 and 154 |
| `src/client/routes/setups/$setupId.tsx` | `src/client/hooks/useSetups.ts` | usePublicSetup hook | WIRED | Line 11: `usePublicSetup` imported; lines 33-36: conditional fetch logic using hook |

### Data-Flow Trace (Level 4)

| Artifact | Data Variable | Source | Produces Real Data | Status |
|----------|---------------|--------|--------------------|--------|
| `$setupId.tsx` | `setup` | `usePublicSetup` → `GET /api/setups/:id/public` → `getPublicSetupWithItems` | DB queries: `setups` table (line 88) + `setupItems` JOIN `items` JOIN `categories` (line 95-132) | FLOWING (items/totals); imageUrl STATIC (undefined — withImageUrls not called) |
| `$globalItemId.tsx` | `item` | `useGlobalItem` → `GET /api/global-items/:id` | DB query in globalItems service | FLOWING |
| `$userId.tsx` | `profile` | `usePublicProfile` → `GET /api/users/:id/profile` → `getPublicProfile` | DB queries: users table (line 37) + setups (line 49) with SQL aggregates | FLOWING |

### Behavioral Spot-Checks

| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| Rate limit tests pass | `bun test tests/middleware/rateLimit.test.ts` | 11 pass, 0 fail | PASS |
| Lint clean in src/ | `bun run lint` (src/ only) | 0 errors in src/ (4 pre-existing .obsidian/ format errors) | PASS |
| createRateLimit factory exported | grep pattern in rateLimit.ts | `export function createRateLimit(maxAttempts: number, windowMs: number)` at line 23 | PASS |
| browseTier applied before auth | Line order check in index.ts | Rate limits lines 122-148, auth middleware line 151 | PASS |
| Public setup endpoint exists | setups.ts route check | `app.get("/:id/public"` at line 45; delegates to getPublicSetupWithItems | PASS |
| imageUrl on public setup items | TS error check | TS2339 at $setupId.tsx:284 — item.imageUrl does not exist on SetupItemWithCategory | FAIL |

### Requirements Coverage

| Requirement | Source Plan | Description | Status | Evidence |
|-------------|------------|-------------|--------|----------|
| PUBL-01 | 24-02 | Browse global item catalog without logging in | SATISFIED | isPublicRoute includes /global-items; auth middleware skips GET /api/global-items; catalog route accessible |
| PUBL-02 | 24-02 | View public setups without logging in | PARTIAL | Setup items and totals accessible via usePublicSetup; images missing for anon users (withImageUrls not called in public endpoint) |
| PUBL-03 | 24-02 | View user profiles without logging in | SATISFIED | isPublicRoute includes /users/; auth skips GET /api/users/:id/profile; getPublicProfile queries DB |
| PUBL-04 | 24-02 | No auth spinner or redirect on first visit | SATISFIED | authLoading spinner block removed; isPublicRoute expanded; soft navigate() fires only after authLoading resolves |
| PUBL-05 | 24-02 | Login required only for create/edit/delete | SATISFIED | Write actions in $globalItemId.tsx and $setupId.tsx guarded by isAuthenticated; unauthenticated users see AuthPromptModal |
| INFR-01 | 24-01 | Public API endpoints rate-limited | SATISFIED | createRateLimit factory; browseTier (120/min) and detailTier (60/min) applied to all 5 public GET endpoint groups |

All 6 requirements claimed by phase 24 plans are accounted for. No orphaned requirements found in REQUIREMENTS.md traceability table.

### Anti-Patterns Found

| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| `src/client/routes/setups/$setupId.tsx` | 284 | `imageUrl={item.imageUrl}` — property does not exist on `SetupItemWithCategory` | Warning | Item images silently absent for anonymous viewers of public setups. TypeScript error TS2339 confirms the type gap. No runtime crash but visual content gap. |
| `src/server/services/profile.service.ts` | 87-135 | `getPublicSetupWithItems` returns items without presigned image URLs | Warning | Companion to above — public endpoint does not enrich items with S3 presigned URLs. Private endpoint calls `withImageUrls`; public endpoint does not. |

No TODO/FIXME/placeholder comments found in phase-modified files. No empty implementations. No console.log-only handlers.

### Human Verification Required

#### 1. Public Setup Page Renders Read-Only

**Test:** Open an incognito browser window, navigate to a public setup URL (e.g., `/setups/1` if a public setup exists). Verify setup name, item list with weights/costs, and total weight/cost render. Confirm no "Add Items", "Delete Setup", or "Public/Private" toggle buttons are visible.
**Expected:** Full read-only view of the setup. No write-action controls.
**Why human:** Visual confirmation of rendered content and conditional UI requires browser.

#### 2. Auth Prompt Modal Interaction

**Test:** Open an incognito window, navigate to a catalog item detail page (`/global-items/:id`), click "Add to Collection". Verify the AuthPromptModal appears with "Join GearBox" heading and "sign in or sign up" text. Test backdrop click, Escape key, and "Sign in" / "Create account" button routes.
**Expected:** Modal appears on first click, dismisses on backdrop/Escape, both buttons navigate to `/login`.
**Why human:** Modal interaction, keyboard events, and navigation behavior require browser verification.

#### 3. Render-First on Anonymous Visit

**Test:** Open an incognito window, navigate to `/`. Verify the app renders immediately without a spinner. Navigate to `/global-items`. Confirm catalog loads without redirect to `/login`.
**Expected:** Instant render, no spinner, no redirect.
**Why human:** Render timing and absence of auth redirect requires browser observation.

### Gaps Summary

One functional gap was found that prevents full goal achievement for PUBL-02:

**Image display in public setup views** — When an anonymous user views a public setup at `/setups/:id`, item images will not display. The root cause is a missing `withImageUrls` call in `getPublicSetupWithItems`. The private `getSetupWithItems` in `setup.service.ts` calls `withImageUrls` to generate presigned S3 URLs and attaches them as `imageUrl` on each item. The public equivalent in `profile.service.ts` does not. The client code (`$setupId.tsx:284`) passes `item.imageUrl` to `ItemCard`, but `SetupItemWithCategory` has no such field — TypeScript confirms this with TS2339. The result is silent: no crash, items and totals render, but images are absent.

The fix requires either: (a) calling `withImageUrls` in `getPublicSetupWithItems` and returning the enriched items, or (b) removing the `imageUrl={item.imageUrl}` prop from the public render path.

This gap does not block the core browsing experience (items, names, weights, totals all work) but falls short of the full read-only parity the phase intended.

---

_Verified: 2026-04-10T12:00:00Z_
_Verifier: Claude (gsd-verifier)_
