# Phase 18: Global Items & Public Profiles - Research

**Researched:** 2026-04-04
**Domain:** Full-stack feature: new database tables, services, routes, and client pages for global item catalog and user profiles
**Confidence:** HIGH

## Summary

Phase 18 adds two interconnected features: (1) a global item catalog that all users share, searchable by brand/model, with owner count derived from user item links; and (2) user profiles with display name, avatar, and bio, plus setup visibility toggling. Both features follow the existing service/route/hook/page pattern established across the codebase.

The codebase already uses PostgreSQL via Drizzle ORM (`drizzle-orm/pg-core`), so ILIKE search, boolean columns, and junction tables are native operations. The image upload and presigned URL infrastructure (MinIO/S3) from Phase 17 is ready for avatar uploads. TanStack Router file-based routing means new pages just need new route files in `src/client/routes/`.

**Primary recommendation:** Follow the existing CRUD pattern exactly (schema -> service -> route -> Zod schema -> hook -> page). Global items need a new service file; profiles extend the existing auth service and users table. Public endpoints bypass the `requireAuth` middleware by registering routes before or outside the `/api/*` auth middleware, or by adding path-specific skips.

<user_constraints>

## User Constraints (from CONTEXT.md)

### Locked Decisions
- D-01: Create `globalItems` table: `id` (serial), `brand` (text, not null), `model` (text, not null), `category` (text), `weightGrams` (double precision), `priceCents` (integer), `imageUrl` (text), `description` (text), `createdAt` (timestamp). Separate from user items table.
- D-02: Create `itemGlobalLinks` junction table: `itemId` (FK -> items), `globalItemId` (FK -> globalItems). A user item can optionally link to one global item.
- D-03: Global items are not user-owned -- they're shared catalog entries. No userId column.
- D-04: Global item search: full-text search on brand + model via `ILIKE` (simple, sufficient for initial catalog size).
- D-05: Global item page shows: brand, model, category, specs (weight/price), image, description, and owner count (count of linked user items).
- D-06: JSON seed file (`src/db/global-items-seed.json`) with curated initial catalog. Migration script imports on first run.
- D-07: Seed covers common bikepacking gear categories as a starting point. Can be expanded later.
- D-08: Extend `users` table with: `displayName` (text), `avatarUrl` (text), `bio` (text). All nullable -- profile is optional.
- D-09: Profile edit page at `/settings/profile` or within existing settings page.
- D-10: Public profile page at `/users/:id` -- shows display name, avatar, bio, and public setups. No auth required.
- D-11: Avatar upload uses existing image upload + MinIO storage (from Phase 17).
- D-12: Add `isPublic` boolean column to `setups` table, default `false`. All existing setups remain private.
- D-13: Public setups are viewable at `/setups/:id/public` (or similar) without authentication.
- D-14: Setup toggle UI in setup edit/detail view -- simple switch/checkbox.
- D-15: Public profile page lists only the user's public setups.
- D-16: `GET /api/global-items` -- search/list global catalog (public, no auth needed)
- D-17: `GET /api/global-items/:id` -- global item detail with owner count (public)
- D-18: `POST /api/items/:id/link` -- link a personal item to a global item (auth required)
- D-19: `DELETE /api/items/:id/link` -- unlink (auth required)
- D-20: `GET /api/users/:id/profile` -- public profile data
- D-21: `PUT /api/auth/profile` -- update own profile (auth required)
- D-22: `GET /api/setups/:id/public` -- public setup view (no auth)

### Claude's Discretion
- Exact seed data content and quantity
- Global item search implementation details (ILIKE vs tsvector)
- Profile page layout and component structure
- Public setup URL scheme
- Whether to add a "link to global item" button in item edit form or a separate flow
- Avatar upload integration with existing ImageUpload component
- MCP tool additions for global items

### Deferred Ideas (OUT OF SCOPE)
- Freeform reviews/ratings (requires moderation -- future milestone)
- Follow users / activity feeds (social features -- future milestone)
- Comments on setups (moderation needed -- future milestone)
- Fork/copy public setups as templates (future feature)

</user_constraints>

<phase_requirements>

## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| GLOB-01 | A global item catalog exists with brand, model, category, manufacturer specs, and image | New `globalItems` table in schema.ts, new global-item.service.ts, seed JSON file |
| GLOB-02 | Global catalog is seeded with initial items from manufacturer data | JSON seed file + migration/seed script that imports on first run |
| GLOB-03 | User can search the global catalog by name or brand | `ilike` operator from drizzle-orm on brand/model columns |
| GLOB-04 | User can link a personal collection item to a global catalog entry | `itemGlobalLinks` junction table, link/unlink endpoints on item routes |
| GLOB-05 | Global item pages show basic info and owner count | SQL COUNT on itemGlobalLinks joined to globalItems |
| PROF-01 | User has a profile with display name, avatar, and bio | Add nullable columns to `users` table, profile update endpoint |
| PROF-02 | User can view their own public profile page | Public profile route at `/users/$userId`, fetches from `/api/users/:id/profile` |
| PROF-03 | User can set a setup as public or private | `isPublic` boolean column on setups, toggle in setup detail view |
| PROF-04 | Public setups are viewable by anyone without authentication | Public setup endpoint that skips auth middleware |
| PROF-05 | Public profile page lists the user's public setups | Profile endpoint joins setups where isPublic=true and userId matches |

</phase_requirements>

## Project Constraints (from CLAUDE.md)

- **Stack**: React 19 + Hono + Drizzle ORM + PostgreSQL, running on Bun
- **Routing**: TanStack Router file-based routes -- never edit `routeTree.gen.ts` manually
- **Data fetching**: TanStack React Query via custom hooks in `src/client/hooks/`
- **Validation**: Zod schemas in `src/shared/schemas.ts` (source of truth for types)
- **Types**: Inferred from Zod schemas + Drizzle table definitions in `src/shared/types.ts` -- no manual type duplication
- **Services**: Pure business logic, take db instance, no HTTP awareness
- **Prices as cents**: `priceCents: integer`
- **Styling**: Tailwind CSS v4
- **Lint**: Biome (tabs, double quotes, organized imports)
- **Testing**: Bun test runner, PGlite for in-memory test databases
- **Images**: MinIO/S3 storage with presigned URLs, URL enrichment at route level not service level
- **Auth**: Public-read, authenticated-write. `requireAuth` middleware on `/api/*`
- **Branching**: Feature branch off Develop, merge via PR

## Standard Stack

### Core (already in project)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| drizzle-orm | ^0.45.1 | ORM for schema, queries, migrations | Already used throughout |
| drizzle-kit | ^0.31.9 | Migration generation | Already used |
| hono | ^4.12.8 | HTTP routes + middleware | Already used |
| @hono/zod-validator | (installed) | Request validation | Already used |
| zod | ^4.3.6 | Schema validation | Already used |
| @tanstack/react-query | ^5.90.21 | Server state management | Already used |
| @tanstack/react-router | ^1.167.0 | File-based client routing | Already used |
| @aws-sdk/client-s3 | (installed) | Image upload to MinIO | Already used for image storage |

### No New Dependencies Required
This phase uses only existing libraries. No new packages needed.

## Architecture Patterns

### New Files to Create

```
src/
├── db/
│   ├── schema.ts                          # MODIFY: add globalItems, itemGlobalLinks, users profile cols, setups isPublic
│   └── global-items-seed.json             # NEW: seed data for global catalog
├── server/
│   ├── services/
│   │   ├── global-item.service.ts         # NEW: global item CRUD + search + owner count
│   │   └── profile.service.ts             # NEW: profile CRUD + public profile data
│   ├── routes/
│   │   ├── global-items.ts                # NEW: /api/global-items routes
│   │   └── profiles.ts                    # NEW: /api/users/:id/profile + public setup routes
│   └── index.ts                           # MODIFY: register new routes
├── shared/
│   ├── schemas.ts                         # MODIFY: add global item + profile + setup visibility schemas
│   └── types.ts                           # MODIFY: add new types
├── client/
│   ├── hooks/
│   │   ├── useGlobalItems.ts              # NEW: global item queries
│   │   └── useProfile.ts                  # NEW: profile queries + mutations
│   ├── routes/
│   │   ├── global-items/
│   │   │   ├── index.tsx                  # NEW: global catalog search/browse page
│   │   │   └── $globalItemId.tsx          # NEW: global item detail page
│   │   └── users/
│   │       └── $userId.tsx                # NEW: public profile page
│   └── components/
│       └── (new components as needed)     # Profile card, global item card, etc.
tests/
├── services/
│   ├── global-item.service.test.ts        # NEW
│   └── profile.service.test.ts            # NEW
├── routes/
│   ├── global-items.test.ts               # NEW
│   └── profiles.test.ts                   # NEW
```

### Pattern 1: Schema Additions (Drizzle pg-core)

**What:** New tables and column additions using existing Drizzle patterns.
**When to use:** All schema changes in this phase.

```typescript
// In src/db/schema.ts -- add boolean import
import {
  boolean,      // NEW for this phase
  doublePrecision,
  integer,
  pgTable,
  primaryKey,
  serial,
  text,
  timestamp,
  unique,
} from "drizzle-orm/pg-core";

// Global Items table (no userId -- shared catalog)
export const globalItems = pgTable("global_items", {
  id: serial("id").primaryKey(),
  brand: text("brand").notNull(),
  model: text("model").notNull(),
  category: text("category"),
  weightGrams: doublePrecision("weight_grams"),
  priceCents: integer("price_cents"),
  imageUrl: text("image_url"),
  description: text("description"),
  createdAt: timestamp("created_at").defaultNow().notNull(),
});

// Junction table: user item <-> global item (1:1 from item side)
export const itemGlobalLinks = pgTable("item_global_links", {
  id: serial("id").primaryKey(),
  itemId: integer("item_id")
    .notNull()
    .references(() => items.id, { onDelete: "cascade" })
    .unique(),  // Each user item links to at most one global item
  globalItemId: integer("global_item_id")
    .notNull()
    .references(() => globalItems.id, { onDelete: "cascade" }),
});

// Extend users table -- add profile columns:
// displayName: text("display_name"),
// avatarUrl: text("avatar_url"),
// bio: text("bio"),

// Extend setups table -- add visibility:
// isPublic: boolean("is_public").notNull().default(false),
```

### Pattern 2: ILIKE Search in Drizzle

**What:** PostgreSQL case-insensitive pattern matching for global item search.
**When to use:** `GET /api/global-items?q=search_term`

```typescript
import { ilike, or, sql } from "drizzle-orm";

export async function searchGlobalItems(db: Db, query?: string) {
  const baseQuery = db.select().from(globalItems);

  if (query) {
    const pattern = `%${query}%`;
    return baseQuery.where(
      or(
        ilike(globalItems.brand, pattern),
        ilike(globalItems.model, pattern),
      )
    );
  }

  return baseQuery;
}
```

### Pattern 3: Public Routes (No Auth Required)

**What:** Some endpoints in this phase must work without authentication. The current middleware applies `requireAuth` to ALL `/api/*` routes except `/api/auth/*`.
**When to use:** Global item GET endpoints, public profile, public setup view.

Two approaches:
1. **Add path skips in the auth middleware** (recommended -- minimal change):
```typescript
// In src/server/index.ts auth middleware
app.use("/api/*", async (c, next) => {
  if (c.req.path.startsWith("/api/auth")) return next();
  if (c.req.path === "/api/health") return next();
  // NEW: skip auth for public-read endpoints
  if (c.req.path.startsWith("/api/global-items") && c.req.method === "GET") return next();
  if (c.req.path.match(/^\/api\/users\/\d+\/profile$/) && c.req.method === "GET") return next();
  if (c.req.path.match(/^\/api\/setups\/\d+\/public$/) && c.req.method === "GET") return next();
  return requireAuth(c, next);
});
```

2. **Register public routes before the auth middleware** (cleaner but requires route restructuring).

Recommendation: Use approach 1 -- add specific GET-method skips. It's consistent with the existing `/api/auth` and `/api/health` skip pattern.

**Important:** The `userId` will be undefined for unauthenticated requests. Public service functions must NOT require userId.

### Pattern 4: Owner Count via SQL

**What:** Count how many user items link to a global item.
**When to use:** Global item detail page (GLOB-05).

```typescript
import { count, eq } from "drizzle-orm";

export async function getGlobalItemWithOwnerCount(db: Db, id: number) {
  const [item] = await db.select().from(globalItems).where(eq(globalItems.id, id));
  if (!item) return null;

  const [{ ownerCount }] = await db
    .select({ ownerCount: count() })
    .from(itemGlobalLinks)
    .where(eq(itemGlobalLinks.globalItemId, id));

  return { ...item, ownerCount };
}
```

### Pattern 5: Seed Script for Global Items

**What:** Import JSON seed data into globalItems table.
**When to use:** First-run or migration (GLOB-02).

```typescript
// src/db/seed-global-items.ts
import seedData from "./global-items-seed.json";
import { globalItems } from "./schema.ts";

export async function seedGlobalItems(db: Db) {
  const existing = await db.select({ id: globalItems.id }).from(globalItems).limit(1);
  if (existing.length > 0) return; // Already seeded

  await db.insert(globalItems).values(seedData);
}
```

The JSON seed file should contain an array of objects matching the globalItems schema (without `id` and `createdAt`).

### Anti-Patterns to Avoid
- **Don't add userId to globalItems:** These are shared catalog entries, not user-owned data (D-03).
- **Don't use full-text search (tsvector):** ILIKE is sufficient for the initial catalog size (D-04). tsvector adds complexity for minimal benefit at this scale.
- **Don't enrich image URLs in services:** Follow the Phase 17 pattern -- URL enrichment happens at the route level, keeping services storage-agnostic.
- **Don't duplicate types:** Infer from Zod schemas and Drizzle table definitions per project convention.
- **Don't make existing setups public by default:** D-12 says default `false`, all existing setups remain private.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| ILIKE search | Custom string matching | `ilike()` from drizzle-orm | Built-in, SQL-injection safe, handles escaping |
| Image presigned URLs | Custom URL signing | `withImageUrl`/`withImageUrls` from storage.service.ts | Already built in Phase 17 |
| File upload handling | Custom multipart parser | Existing `POST /api/images` endpoint + ImageUpload component | Avatar upload reuses existing infrastructure |
| Route parameter validation | Manual parseInt | `parseId()` from `src/server/lib/params.ts` | Already handles NaN, negatives |
| Query invalidation | Manual cache management | TanStack React Query `invalidateQueries` | Standard pattern across all hooks |

## Common Pitfalls

### Pitfall 1: Auth Middleware Blocking Public Endpoints
**What goes wrong:** New GET endpoints for global items, public profiles, and public setups return 401 because they go through `requireAuth`.
**Why it happens:** The auth middleware in `index.ts` applies to ALL `/api/*` routes.
**How to avoid:** Add explicit path/method checks before `requireAuth` call. Test unauthenticated access in route tests.
**Warning signs:** Public pages showing "Authentication required" errors.

### Pitfall 2: userId Undefined on Public Routes
**What goes wrong:** Service functions try to use `userId` from context on public endpoints, causing crashes.
**Why it happens:** Public endpoints skip auth, so `c.get("userId")` is undefined.
**How to avoid:** Public service functions must NOT take userId as required parameter. Use separate service functions for public data access.
**Warning signs:** TypeError on undefined when accessing public pages.

### Pitfall 3: Missing Migration for Column Additions
**What goes wrong:** Adding columns to existing tables (users, setups) without generating and applying a migration.
**Why it happens:** Forgetting `bun run db:generate` after schema changes.
**How to avoid:** Always run `bun run db:generate` after any schema.ts change, then `bun run db:push`.
**Warning signs:** Column not found errors at runtime.

### Pitfall 4: Seed Data Idempotency
**What goes wrong:** Global items get duplicated on every server restart.
**Why it happens:** Seed script runs without checking if data already exists.
**How to avoid:** Check for existing rows before inserting. Use a guard like `SELECT COUNT(*) FROM global_items`.
**Warning signs:** Duplicate entries in global catalog.

### Pitfall 5: ILIKE SQL Injection via Wildcards
**What goes wrong:** User search input containing `%` or `_` matches unintended rows.
**Why it happens:** These are LIKE wildcards. A search for "100%" would match everything.
**How to avoid:** Escape `%` and `_` in user input before wrapping in `%..%`. Replace `%` with `\%` and `_` with `\_`.
**Warning signs:** Unexpected search results with special characters.

### Pitfall 6: TanStack Router Route Tree Not Regenerating
**What goes wrong:** New route files exist but pages 404.
**Why it happens:** The route tree auto-generation didn't run after adding new route files.
**How to avoid:** Run `bun run dev:client` (or the Vite dev server) -- it watches for new route files. Or run the TanStack Router plugin manually.
**Warning signs:** New routes return 404, `routeTree.gen.ts` doesn't include new routes.

### Pitfall 7: Boolean Column Default in Existing Rows
**What goes wrong:** Migration adds `isPublic` column but existing rows have NULL instead of false.
**Why it happens:** Adding a nullable boolean column without `.notNull().default(false)`.
**How to avoid:** Define as `boolean("is_public").notNull().default(false)` -- Drizzle generates the migration with a DEFAULT clause that backfills existing rows.
**Warning signs:** Existing setups show as `null` visibility instead of private.

## Code Examples

### Zod Schemas for New Features

```typescript
// In src/shared/schemas.ts

// Global item schemas
export const searchGlobalItemsSchema = z.object({
  q: z.string().optional(),
});

export const linkItemSchema = z.object({
  globalItemId: z.number().int().positive(),
});

// Profile schemas
export const updateProfileSchema = z.object({
  displayName: z.string().max(100).optional(),
  avatarUrl: z.string().optional(),
  bio: z.string().max(500).optional(),
});

// Setup visibility
export const updateSetupSchema = z.object({
  name: z.string().min(1, "Setup name is required"),
  isPublic: z.boolean().optional(),
});
```

### Public Setup Service Function

```typescript
// No userId required -- this is a public endpoint
export async function getPublicSetupWithItems(db: Db, setupId: number) {
  const [setup] = await db
    .select()
    .from(setups)
    .where(and(eq(setups.id, setupId), eq(setups.isPublic, true)));

  if (!setup) return null;

  const itemList = await db
    .select({
      id: items.id,
      name: items.name,
      weightGrams: items.weightGrams,
      priceCents: items.priceCents,
      quantity: items.quantity,
      categoryName: categories.name,
      categoryIcon: categories.icon,
      classification: setupItems.classification,
    })
    .from(setupItems)
    .innerJoin(items, eq(setupItems.itemId, items.id))
    .innerJoin(categories, eq(items.categoryId, categories.id))
    .where(eq(setupItems.setupId, setupId));

  return { ...setup, items: itemList };
}
```

### Profile Query in Public Profile Route

```typescript
// Public profile: user info + public setups
export async function getPublicProfile(db: Db, userId: number) {
  const [user] = await db
    .select({
      id: users.id,
      displayName: users.displayName,
      avatarUrl: users.avatarUrl,
      bio: users.bio,
    })
    .from(users)
    .where(eq(users.id, userId));

  if (!user) return null;

  const publicSetups = await db
    .select({
      id: setups.id,
      name: setups.name,
      createdAt: setups.createdAt,
    })
    .from(setups)
    .where(and(eq(setups.userId, userId), eq(setups.isPublic, true)));

  return { ...user, setups: publicSetups };
}
```

### Client Hook Pattern

```typescript
// src/client/hooks/useGlobalItems.ts
export function useGlobalItems(query?: string) {
  return useQuery({
    queryKey: ["global-items", query],
    queryFn: () => {
      const params = query ? `?q=${encodeURIComponent(query)}` : "";
      return apiGet<GlobalItem[]>(`/api/global-items${params}`);
    },
  });
}

export function useGlobalItem(id: number | null) {
  return useQuery({
    queryKey: ["global-items", id],
    queryFn: () => apiGet<GlobalItemWithOwnerCount>(`/api/global-items/${id}`),
    enabled: id != null,
  });
}
```

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| SQLite schema | PostgreSQL via drizzle-orm/pg-core | Phase 14 | boolean type natively available, ILIKE supported |
| Local file images | MinIO/S3 presigned URLs | Phase 17 | Avatar upload uses existing infrastructure |
| Single-user (no userId) | Multi-user with userId scoping | Phase 16 | All new endpoints need userId awareness |
| Cookie sessions only | OIDC + API keys + OAuth | Phase 15 | Auth middleware already handles all auth methods |

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | Bun test runner |
| Config file | bunfig.toml (if exists) / none |
| Quick run command | `bun test tests/services/global-item.service.test.ts` |
| Full suite command | `bun test` |

### Phase Requirements -> Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| GLOB-01 | Global items table CRUD | unit | `bun test tests/services/global-item.service.test.ts` | Wave 0 |
| GLOB-02 | Seed data imports correctly | unit | `bun test tests/services/global-item.service.test.ts` | Wave 0 |
| GLOB-03 | Search by brand/model via ILIKE | unit | `bun test tests/services/global-item.service.test.ts` | Wave 0 |
| GLOB-04 | Link/unlink item to global item | unit | `bun test tests/services/global-item.service.test.ts` | Wave 0 |
| GLOB-05 | Owner count on global item detail | unit | `bun test tests/services/global-item.service.test.ts` | Wave 0 |
| PROF-01 | Profile fields on users table | unit | `bun test tests/services/profile.service.test.ts` | Wave 0 |
| PROF-02 | Public profile data endpoint | integration | `bun test tests/routes/profiles.test.ts` | Wave 0 |
| PROF-03 | Setup isPublic toggle | unit | `bun test tests/services/setup.service.test.ts` | Extend existing |
| PROF-04 | Public setup view without auth | integration | `bun test tests/routes/profiles.test.ts` | Wave 0 |
| PROF-05 | Public profile lists public setups only | integration | `bun test tests/routes/profiles.test.ts` | Wave 0 |

### Sampling Rate
- **Per task commit:** `bun test tests/services/global-item.service.test.ts && bun test tests/services/profile.service.test.ts`
- **Per wave merge:** `bun test`
- **Phase gate:** Full suite green before `/gsd:verify-work`

### Wave 0 Gaps
- [ ] `tests/services/global-item.service.test.ts` -- covers GLOB-01 through GLOB-05
- [ ] `tests/services/profile.service.test.ts` -- covers PROF-01
- [ ] `tests/routes/global-items.test.ts` -- covers GLOB-01 through GLOB-05 at route level
- [ ] `tests/routes/profiles.test.ts` -- covers PROF-02 through PROF-05 at route level
- [ ] `createTestDb` helper may need updating to return user with profile fields

## Open Questions

1. **Global item imageUrl handling**
   - What we know: Global items have `imageUrl` (text) which stores a URL string (not a MinIO filename). This is different from user items which store `imageFilename`.
   - What's unclear: Should global item images be stored in MinIO (uploaded at seed time) or reference external URLs?
   - Recommendation: Use external URLs for seed data (manufacturer images). If admin upload is added later, switch to MinIO filenames. Keep the column as `imageUrl` -- it's a URL either way.

2. **Profile edit UI placement**
   - What we know: D-09 says `/settings/profile` or within existing settings page.
   - What's unclear: Separate route or section within `settings.tsx`?
   - Recommendation: Add a "Profile" section within the existing `settings.tsx` page. It already has sections for API Keys, units, currency. A new tab/section keeps navigation simple.

3. **MCP tool additions**
   - What we know: CONTEXT.md lists this as Claude's discretion.
   - What's unclear: Which MCP tools to add for global items.
   - Recommendation: Add `search_global_items` and `get_global_item` tools. Linking can happen through existing `update_item` with a global item reference. Defer to implementation time.

## Sources

### Primary (HIGH confidence)
- `src/db/schema.ts` -- Current Drizzle schema with pg-core imports, confirmed boolean not yet imported
- `src/server/index.ts` -- Auth middleware pattern, route registration
- `src/server/middleware/auth.ts` -- How requireAuth works, path skip pattern
- `src/server/services/item.service.ts` -- Service pattern (db + userId params)
- `src/server/services/setup.service.ts` -- Setup CRUD with SQL aggregates
- `src/server/services/storage.service.ts` -- Image URL enrichment at route level
- `src/client/hooks/useItems.ts` -- Hook pattern with React Query
- `src/shared/schemas.ts` -- Zod validation schema pattern
- `tests/helpers/db.ts` -- PGlite test database creation pattern
- `tests/routes/setups.test.ts` -- Route test pattern with Hono test app

### Secondary (MEDIUM confidence)
- drizzle-orm `ilike` operator -- standard PostgreSQL ILIKE, available in drizzle-orm exports
- drizzle-orm `boolean` column type -- standard in pg-core, not yet used in project but straightforward

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH - no new dependencies, all existing libraries
- Architecture: HIGH - follows established patterns exactly
- Pitfalls: HIGH - derived from direct codebase analysis of auth middleware and service patterns

**Research date:** 2026-04-04
**Valid until:** 2026-05-04 (stable -- no dependency changes expected)
