Compare commits
19 Commits
feature/v1
...
v1.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c7bc2881c | |||
| 412ca60e42 | |||
| 5fdf4c3019 | |||
| 6dcb421fb0 | |||
| f01add3943 | |||
| 1fad25726d | |||
| 7309c080df | |||
| f47e1d74ae | |||
| c04b9b0e09 | |||
| 6a77995530 | |||
| 1344f2f87f | |||
| 64403f6977 | |||
| 443802fc68 | |||
| 642ae0d43f | |||
| f9c6693b63 | |||
| bb60168ffb | |||
| 68f6647f76 | |||
| 0a40d7627f | |||
| 3eccbb12fd |
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
## What This Is
|
## What This Is
|
||||||
|
|
||||||
A web-based gear management and purchase planning app. Users catalog their gear collections (bikepacking, sim racing, or any hobby), track weight, price, and source details, search and filter by name or category, and use planning threads to research and compare new purchases with status tracking. Named setups let users compose loadouts with weight classification (base/worn/consumable), donut chart visualization, and live totals in selectable units. Built as a single-user app with a clean, minimalist interface.
|
A gear management and discovery platform. Users catalog their gear collections (bikepacking, sim racing, or any hobby), track weight, price, and source details, research purchases through planning threads with side-by-side comparison, and compose named setups (loadouts) with weight classification and visualization. A global item database with crowd-verified specs and structured reviews helps users make informed purchase decisions. Multi-user with public setup sharing and gear discovery.
|
||||||
|
|
||||||
## Core Value
|
## Core Value
|
||||||
|
|
||||||
Make it effortless to manage gear and plan new purchases — see how a potential buy affects your total setup weight and cost before committing.
|
Help people make better gear decisions — discover what others use, compare real-world data, and see how a potential buy affects your setup before committing.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -42,45 +42,58 @@ Make it effortless to manage gear and plan new purchases — see how a potential
|
|||||||
|
|
||||||
### Active
|
### Active
|
||||||
|
|
||||||
## Current Milestone: v1.3 Research & Decision Tools
|
## Current Milestone: v2.0 Platform Foundation
|
||||||
|
|
||||||
**Goal:** Give users the tools to actually decide between candidates — compare details side-by-side, see how a pick impacts their setup, and rank/annotate their options.
|
**Goal:** Transform GearBox from a single-user gear tracker into a multi-user platform where people discover gear, research purchases using crowd-verified data, and share their setups.
|
||||||
|
|
||||||
**Target features:**
|
**Target features:**
|
||||||
- Full-detail side-by-side candidate comparison (weight, price, images, notes, links, status)
|
- External auth provider (self-hosted, open-source) for multi-user registration
|
||||||
- Impact preview: pick a setup, see +/- weight and cost delta for each candidate
|
- Migrate from SQLite to Postgres
|
||||||
- Candidate ranking (drag-to-reorder) with pros/cons text fields per candidate
|
- Multi-user data model (user ownership on all entities, public/private visibility)
|
||||||
|
- Global item database (seeded from manufacturer data, enrichable by users)
|
||||||
|
- Public user profiles with shared setups
|
||||||
|
- Structured item reviews (ratings + predefined fields, not freeform text)
|
||||||
|
- Discovery feed (browse setups, new items, popular gear)
|
||||||
|
- Item detail pages with aggregated specs, owner count, setup appearances
|
||||||
|
|
||||||
### Future
|
### Future
|
||||||
|
|
||||||
- [ ] CSV import/export for gear collections
|
- [ ] Freeform reviews with moderation system
|
||||||
- [ ] Multi-user accounts with authentication
|
- [ ] Comments on setups
|
||||||
- [ ] Collection sharing and social features (public profiles, shared setups)
|
- [ ] Follow users / activity feeds
|
||||||
- [ ] Auto-fill product information (price, weight, images) from external sources
|
- [ ] OAuth / social login providers
|
||||||
|
- [ ] User-to-user messaging
|
||||||
|
|
||||||
### Out of Scope
|
### Out of Scope
|
||||||
|
|
||||||
- Custom comparison parameters — complexity trap, weight/price covers 80% of cases
|
- Custom comparison parameters — complexity trap, weight/price covers 80% of cases
|
||||||
- Mobile native app — web-first, responsive design sufficient
|
- Mobile native app — web-first, responsive design sufficient
|
||||||
- Price tracking / deal alerts — requires scraping, fragile
|
- Price tracking / deal alerts — requires scraping, fragile
|
||||||
- Barcode scanning / product database — requires external database
|
- Barcode scanning — poor UX, manual entry is fine with global database
|
||||||
- Community gear database — requires moderation, accounts
|
|
||||||
- Real-time weather integration — only outdoor-specific, GearBox is hobby-agnostic
|
- Real-time weather integration — only outdoor-specific, GearBox is hobby-agnostic
|
||||||
|
- Freeform UGC (reviews, comments) — defer until moderation infrastructure exists
|
||||||
|
- User-to-user messaging — high moderation burden, not core to discovery
|
||||||
|
- Wiki-style open item editing — structured contributions only for data quality
|
||||||
|
- Maintaining SQLite single-user mode in parallel — diverged at v2.0
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
Shipped v1.2 with 7,310 LOC TypeScript. Starting v1.3 to enhance thread decision workflow.
|
Shipped through v1.4 with 11,333 LOC TypeScript across 90 files. Starting v2.0 platform transformation.
|
||||||
Tech stack: React 19, Hono, Drizzle ORM, SQLite, TanStack Router/Query, Tailwind CSS v4, Lucide React, Recharts, all on Bun.
|
Tech stack: React 19, Hono, Drizzle ORM, SQLite (migrating to Postgres), TanStack Router/Query, Tailwind CSS v4, Lucide React, Recharts, framer-motion, all on Bun.
|
||||||
Primary use case is bikepacking gear but data model is hobby-agnostic.
|
Primary use case is bikepacking gear but data model is hobby-agnostic.
|
||||||
Replaces spreadsheet-based gear tracking workflow.
|
Existing auth: single-user with cookie sessions + API keys. Will be replaced by external auth provider.
|
||||||
121 tests (service-level and route-level integration).
|
Existing features: MCP server (19 tools), E2E tests (Playwright), CSV import/export, item comparison, candidate ranking, setup impact preview.
|
||||||
|
21 test files (service-level, route-level integration, and E2E).
|
||||||
|
|
||||||
## Constraints
|
## Constraints
|
||||||
|
|
||||||
- **Runtime**: Bun — used as package manager and runtime
|
- **Runtime**: Bun — used as package manager and runtime
|
||||||
- **Design**: Light, airy, minimalist — white/light backgrounds, lots of whitespace, no visual clutter
|
- **Design**: Light, airy, minimalist — white/light backgrounds, lots of whitespace, no visual clutter
|
||||||
- **Navigation**: Dashboard-based home page, not sidebar or top-nav tabs
|
- **Navigation**: Dashboard-based home page, not sidebar or top-nav tabs
|
||||||
- **Scope**: Single user with cookie/API key auth
|
- **Auth**: External self-hosted provider — no in-house auth maintenance
|
||||||
|
- **Database**: Postgres for platform deployment
|
||||||
|
- **UGC**: Structured input only (ratings, predefined fields) — no freeform text until moderation exists
|
||||||
|
- **Scope**: Multi-user platform with public discovery
|
||||||
|
|
||||||
## Key Decisions
|
## Key Decisions
|
||||||
|
|
||||||
@@ -105,6 +118,12 @@ Replaces spreadsheet-based gear tracking workflow.
|
|||||||
| Hero image area at top of forms | Image-first UX, 4:3 aspect ratio consistent with cards | ✓ Good |
|
| Hero image area at top of forms | Image-first UX, 4:3 aspect ratio consistent with cards | ✓ Good |
|
||||||
| Emoji-to-icon automatic migration | One-time schema rename + data conversion via Drizzle migration | ✓ Good |
|
| Emoji-to-icon automatic migration | One-time schema rename + data conversion via Drizzle migration | ✓ Good |
|
||||||
| ALTER TABLE RENAME COLUMN for SQLite | Simpler than table recreation for column rename | ✓ Good |
|
| ALTER TABLE RENAME COLUMN for SQLite | Simpler than table recreation for column rename | ✓ Good |
|
||||||
|
| Platform pivot at v2.0 | Single-user model proven, now build for multi-user discovery | — Pending |
|
||||||
|
| External auth provider | Avoid in-house auth security burden, self-hosted + open-source | — Pending |
|
||||||
|
| SQLite → Postgres | Multi-user platform needs proper concurrent DB; auth provider needs Postgres anyway | — Pending |
|
||||||
|
| Single-user mode diverges at v2.0 | Platform features irrelevant for solo use; maintain as separate artifact if needed | — Pending |
|
||||||
|
| Structured UGC only (no freeform) | Minimize moderation burden; ratings + predefined fields cover 80% of value | — Pending |
|
||||||
|
| Discovery-first, not social-first | Users come to research gear decisions, not to build social graphs | — Pending |
|
||||||
| Weight conversion precision: g=0dp, oz=1dp, lb=2dp, kg=2dp | Matches common usage conventions | ✓ Good |
|
| Weight conversion precision: g=0dp, oz=1dp, lb=2dp, kg=2dp | Matches common usage conventions | ✓ Good |
|
||||||
| Unit toggle in TotalsBar (not settings page) | Visible, quick access for frequent switching | ✓ Good |
|
| Unit toggle in TotalsBar (not settings page) | Visible, quick access for frequent switching | ✓ Good |
|
||||||
| CategoryFilterDropdown separate from CategoryPicker | Filter vs form concerns are different | ✓ Good |
|
| CategoryFilterDropdown separate from CategoryPicker | Filter vs form concerns are different | ✓ Good |
|
||||||
@@ -115,5 +134,22 @@ Replaces spreadsheet-based gear tracking workflow.
|
|||||||
| Classification-preserving sync via Map | Save metadata before delete, restore after re-insert | ✓ Good |
|
| Classification-preserving sync via Map | Save metadata before delete, restore after re-insert | ✓ Good |
|
||||||
| Recharts for charting | Mature React chart library, composable API | ✓ Good |
|
| Recharts for charting | Mature React chart library, composable API | ✓ Good |
|
||||||
|
|
||||||
|
## Evolution
|
||||||
|
|
||||||
|
This document evolves at phase transitions and milestone boundaries.
|
||||||
|
|
||||||
|
**After each phase transition** (via `/gsd:transition`):
|
||||||
|
1. Requirements invalidated? → Move to Out of Scope with reason
|
||||||
|
2. Requirements validated? → Move to Validated with phase reference
|
||||||
|
3. New requirements emerged? → Add to Active
|
||||||
|
4. Decisions to log? → Add to Key Decisions
|
||||||
|
5. "What This Is" still accurate? → Update if drifted
|
||||||
|
|
||||||
|
**After each milestone** (via `/gsd:complete-milestone`):
|
||||||
|
1. Full review of all sections
|
||||||
|
2. Core Value check — still the right priority?
|
||||||
|
3. Audit Out of Scope — reasons still valid?
|
||||||
|
4. Update Context with current state
|
||||||
|
|
||||||
---
|
---
|
||||||
*Last updated: 2026-03-16 after v1.3 milestone start*
|
*Last updated: 2026-04-03 after v2.0 milestone start*
|
||||||
|
|||||||
@@ -1,52 +1,94 @@
|
|||||||
# Requirements: GearBox v1.3 Research & Decision Tools
|
# Requirements: GearBox v2.0 Platform Foundation
|
||||||
|
|
||||||
**Defined:** 2026-03-16
|
**Defined:** 2026-04-03
|
||||||
**Core Value:** Make it effortless to manage gear and plan new purchases -- see how a potential buy affects your total setup weight and cost before committing.
|
**Core Value:** Help people make better gear decisions — discover what others use, compare real-world data, and see how a potential buy affects your setup before committing.
|
||||||
|
|
||||||
## v1.3 Requirements
|
## v2.0 Requirements
|
||||||
|
|
||||||
Requirements for this milestone. Each maps to roadmap phases.
|
Requirements for this milestone. Each maps to roadmap phases.
|
||||||
|
|
||||||
### Comparison View
|
### Database Migration
|
||||||
|
|
||||||
- [x] **COMP-01**: User can view candidates side-by-side in a tabular comparison layout (weight, price, images, notes, links, status)
|
- [ ] **DB-01**: Application runs on PostgreSQL instead of SQLite
|
||||||
- [x] **COMP-02**: User can see relative deltas highlighting the lightest and cheapest candidate with +/- differences
|
- [ ] **DB-02**: All service functions use async database operations
|
||||||
- [x] **COMP-03**: Comparison table scrolls horizontally with a sticky label column on narrow viewports
|
- [ ] **DB-03**: Test infrastructure uses PGlite instead of bun:sqlite in-memory databases
|
||||||
- [x] **COMP-04**: Comparison view displays read-only summary for resolved threads
|
- [ ] **DB-04**: Existing SQLite data can be migrated to Postgres via a one-time script
|
||||||
|
- [ ] **DB-05**: Docker Compose provides Postgres for local development
|
||||||
|
|
||||||
### Candidate Ranking
|
### Authentication
|
||||||
|
|
||||||
- [x] **RANK-01**: User can drag candidates to reorder priority ranking within a thread
|
- [ ] **AUTH-01**: User can register an account via external OIDC auth provider
|
||||||
- [x] **RANK-02**: Top 3 ranked candidates display rank badges (gold, silver, bronze)
|
- [ ] **AUTH-02**: User can log in via external auth provider and access their data
|
||||||
- [x] **RANK-03**: User can add pros and cons text per candidate displayed as bullet lists
|
- [ ] **AUTH-03**: API keys remain functional for programmatic access (MCP, scripts)
|
||||||
- [x] **RANK-04**: Candidate rank order persists across sessions
|
- [ ] **AUTH-04**: Auth provider runs self-hosted alongside the application
|
||||||
- [x] **RANK-05**: Drag handles and ranking are disabled on resolved threads
|
- [ ] **AUTH-05**: E2E tests authenticate via API keys without depending on the auth provider
|
||||||
|
|
||||||
### Impact Preview
|
### Multi-User Data Model
|
||||||
|
|
||||||
- [ ] **IMPC-01**: User can select a setup and see weight and cost delta for each candidate
|
- [ ] **MULTI-01**: Every item, category, thread, and setup is owned by a specific user
|
||||||
- [ ] **IMPC-02**: Impact preview auto-detects replace mode when a setup item exists in the same category as the thread
|
- [ ] **MULTI-02**: User can only see and modify their own data (cross-user isolation)
|
||||||
- [ ] **IMPC-03**: Impact preview shows add mode (pure addition) when no category match exists in the selected setup
|
- [ ] **MULTI-03**: Categories use composite unique constraint (userId + name)
|
||||||
- [ ] **IMPC-04**: Candidates with missing weight data show a clear indicator instead of misleading zero deltas
|
- [ ] **MULTI-04**: Existing data is assigned to the original user during migration
|
||||||
|
- [ ] **MULTI-05**: MCP tools operate within the authenticated user's scope
|
||||||
|
- [ ] **MULTI-06**: Settings are per-user rather than global
|
||||||
|
|
||||||
|
### Image Storage
|
||||||
|
|
||||||
|
- [ ] **IMG-01**: Images are stored in MinIO (S3-compatible) instead of local filesystem
|
||||||
|
- [ ] **IMG-02**: Existing uploaded images are migrated to MinIO
|
||||||
|
- [ ] **IMG-03**: Image upload and retrieval work through the new storage layer
|
||||||
|
- [ ] **IMG-04**: Docker Compose provides MinIO for local development
|
||||||
|
|
||||||
|
### Global Item Database
|
||||||
|
|
||||||
|
- [ ] **GLOB-01**: A global item catalog exists with brand, model, category, manufacturer specs, and image
|
||||||
|
- [ ] **GLOB-02**: Global catalog is seeded with initial items from manufacturer data
|
||||||
|
- [ ] **GLOB-03**: User can search the global catalog by name or brand
|
||||||
|
- [ ] **GLOB-04**: User can link a personal collection item to a global catalog entry
|
||||||
|
- [ ] **GLOB-05**: Global item pages show basic info and owner count
|
||||||
|
|
||||||
|
### User Profiles & Sharing
|
||||||
|
|
||||||
|
- [ ] **PROF-01**: User has a profile with display name, avatar, and bio
|
||||||
|
- [ ] **PROF-02**: User can view their own public profile page
|
||||||
|
- [ ] **PROF-03**: User can set a setup as public or private
|
||||||
|
- [ ] **PROF-04**: Public setups are viewable by anyone without authentication
|
||||||
|
- [ ] **PROF-05**: Public profile page lists the user's public setups
|
||||||
|
|
||||||
## Future Requirements
|
## Future Requirements
|
||||||
|
|
||||||
Deferred to future milestones. Tracked but not in current roadmap.
|
Deferred to future milestones. Tracked but not in current roadmap.
|
||||||
|
|
||||||
### Data Management
|
### Reviews & Ratings
|
||||||
|
|
||||||
- **DATA-01**: User can import gear collection from CSV
|
- **REV-01**: User can rate a global item with an overall star rating
|
||||||
- **DATA-02**: User can export gear collection to CSV
|
- **REV-02**: User can rate a global item on predefined dimensions (durability, value, etc.)
|
||||||
|
- **REV-03**: Item detail pages show average ratings from all reviewers
|
||||||
|
|
||||||
### Social & Multi-User
|
### Discovery
|
||||||
|
|
||||||
- **SOCL-01**: User can create an account with authentication
|
- **DISC-01**: User can browse recently shared public setups
|
||||||
- **SOCL-02**: User can share collections and setups publicly
|
- **DISC-02**: User can browse recently reviewed items
|
||||||
- **SOCL-03**: User can view other users' public profiles and setups
|
- **DISC-03**: User can browse popular gear by owner count
|
||||||
|
|
||||||
### Automation
|
### Aggregation
|
||||||
|
|
||||||
- **AUTO-01**: System can auto-fill product information (price, weight, images) from external sources
|
- **AGG-01**: Item detail pages show crowd-verified specs (manufacturer vs community-measured weight)
|
||||||
|
- **AGG-02**: Item detail pages show which setups include this item
|
||||||
|
- **AGG-03**: Setup composition insights ("commonly paired with")
|
||||||
|
|
||||||
|
### Social
|
||||||
|
|
||||||
|
- **SOCL-01**: User can fork/copy a public setup as a template
|
||||||
|
- **SOCL-02**: Planning thread candidates can link to global items for auto-populated specs
|
||||||
|
- **SOCL-03**: User can follow other users
|
||||||
|
- **SOCL-04**: User can view an activity feed of followed users' content
|
||||||
|
|
||||||
|
### Content Moderation
|
||||||
|
|
||||||
|
- **MOD-01**: User can submit freeform text reviews
|
||||||
|
- **MOD-02**: User can report inappropriate content
|
||||||
|
- **MOD-03**: Admin can review and act on reported content
|
||||||
|
|
||||||
## Out of Scope
|
## Out of Scope
|
||||||
|
|
||||||
@@ -54,13 +96,19 @@ Explicitly excluded. Documented to prevent scope creep.
|
|||||||
|
|
||||||
| Feature | Reason |
|
| Feature | Reason |
|
||||||
|---------|--------|
|
|---------|--------|
|
||||||
| Custom comparison attributes | Complexity trap -- weight/price covers 80% of cases |
|
| Freeform text reviews | Requires moderation infrastructure not yet built |
|
||||||
| Score/rating calculation | Opaque algorithms distrust; manual ranking expresses user preference better |
|
| Comments on setups | Moderation burden, notification system needed |
|
||||||
| Cross-thread comparison | Candidates are decision-scoped; different categories are not apples-to-apples |
|
| User-to-user messaging | High moderation burden, not core to discovery |
|
||||||
| Classification-aware impact breakdown | Data available but UI complexity high; flat delta covers 90% of use case |
|
| Wiki-style open item editing | Quality control risk; structured contributions only |
|
||||||
| Comparison permalink | Requires auth/multi-user work not in scope for v1 |
|
| Marketplace / buy-sell | Payment processing, fraud, legal liability |
|
||||||
| Mobile-optimized comparison (swipe) | Horizontal scroll works for now |
|
| AI gear recommendations | Training data requirements, hallucination risk |
|
||||||
| Rank badge on card grid view | Low urgency; add when users express confusion |
|
| Gamification (badges, points) | Incentivizes quantity over quality |
|
||||||
|
| Instagram-style infinite scroll | Engagement-maximizing conflicts with utility focus |
|
||||||
|
| Price tracking / deal alerts | Requires scraping, fragile, legal gray area |
|
||||||
|
| Mobile native app | Web-first, responsive design sufficient |
|
||||||
|
| Real-time collaborative setups | WebSocket complexity for niche use case |
|
||||||
|
| Maintaining SQLite single-user mode | Platform features irrelevant for solo use; diverged at v2.0 |
|
||||||
|
| Redis infrastructure | Not needed at v2.0 scale; auth provider (Logto) doesn't require it |
|
||||||
|
|
||||||
## Traceability
|
## Traceability
|
||||||
|
|
||||||
@@ -68,25 +116,42 @@ Which phases cover which requirements. Updated during roadmap creation.
|
|||||||
|
|
||||||
| Requirement | Phase | Status |
|
| Requirement | Phase | Status |
|
||||||
|-------------|-------|--------|
|
|-------------|-------|--------|
|
||||||
| COMP-01 | Phase 12 | Complete |
|
| DB-01 | Phase 14 | Pending |
|
||||||
| COMP-02 | Phase 12 | Complete |
|
| DB-02 | Phase 14 | Pending |
|
||||||
| COMP-03 | Phase 12 | Complete |
|
| DB-03 | Phase 14 | Pending |
|
||||||
| COMP-04 | Phase 12 | Complete |
|
| DB-04 | Phase 14 | Pending |
|
||||||
| RANK-01 | Phase 11 | Complete |
|
| DB-05 | Phase 14 | Pending |
|
||||||
| RANK-02 | Phase 11 | Complete |
|
| AUTH-01 | Phase 15 | Pending |
|
||||||
| RANK-03 | Phase 10 | Complete |
|
| AUTH-02 | Phase 15 | Pending |
|
||||||
| RANK-04 | Phase 11 | Complete |
|
| AUTH-03 | Phase 15 | Pending |
|
||||||
| RANK-05 | Phase 11 | Complete |
|
| AUTH-04 | Phase 15 | Pending |
|
||||||
| IMPC-01 | Phase 13 | Pending |
|
| AUTH-05 | Phase 15 | Pending |
|
||||||
| IMPC-02 | Phase 13 | Pending |
|
| MULTI-01 | Phase 16 | Pending |
|
||||||
| IMPC-03 | Phase 13 | Pending |
|
| MULTI-02 | Phase 16 | Pending |
|
||||||
| IMPC-04 | Phase 13 | Pending |
|
| MULTI-03 | Phase 16 | Pending |
|
||||||
|
| MULTI-04 | Phase 16 | Pending |
|
||||||
|
| MULTI-05 | Phase 16 | Pending |
|
||||||
|
| MULTI-06 | Phase 16 | Pending |
|
||||||
|
| IMG-01 | Phase 17 | Pending |
|
||||||
|
| IMG-02 | Phase 17 | Pending |
|
||||||
|
| IMG-03 | Phase 17 | Pending |
|
||||||
|
| IMG-04 | Phase 17 | Pending |
|
||||||
|
| GLOB-01 | Phase 18 | Pending |
|
||||||
|
| GLOB-02 | Phase 18 | Pending |
|
||||||
|
| GLOB-03 | Phase 18 | Pending |
|
||||||
|
| GLOB-04 | Phase 18 | Pending |
|
||||||
|
| GLOB-05 | Phase 18 | Pending |
|
||||||
|
| PROF-01 | Phase 18 | Pending |
|
||||||
|
| PROF-02 | Phase 18 | Pending |
|
||||||
|
| PROF-03 | Phase 18 | Pending |
|
||||||
|
| PROF-04 | Phase 18 | Pending |
|
||||||
|
| PROF-05 | Phase 18 | Pending |
|
||||||
|
|
||||||
**Coverage:**
|
**Coverage:**
|
||||||
- v1.3 requirements: 13 total
|
- v2.0 requirements: 30 total
|
||||||
- Mapped to phases: 13
|
- Mapped to phases: 30
|
||||||
- Unmapped: 0
|
- Unmapped: 0
|
||||||
|
|
||||||
---
|
---
|
||||||
*Requirements defined: 2026-03-16*
|
*Requirements defined: 2026-04-03*
|
||||||
*Last updated: 2026-03-16*
|
*Last updated: 2026-04-03 after roadmap creation*
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
- ✅ **v1.1 Fixes & Polish** — Phases 4-6 (shipped 2026-03-15)
|
- ✅ **v1.1 Fixes & Polish** — Phases 4-6 (shipped 2026-03-15)
|
||||||
- ✅ **v1.2 Collection Power-Ups** — Phases 7-9 (shipped 2026-03-16)
|
- ✅ **v1.2 Collection Power-Ups** — Phases 7-9 (shipped 2026-03-16)
|
||||||
- 🚧 **v1.3 Research & Decision Tools** — Phases 10-13 (in progress)
|
- 🚧 **v1.3 Research & Decision Tools** — Phases 10-13 (in progress)
|
||||||
|
- 📋 **v2.0 Platform Foundation** — Phases 14-18 (planned)
|
||||||
|
|
||||||
## Phases
|
## Phases
|
||||||
|
|
||||||
@@ -45,6 +46,16 @@
|
|||||||
- [x] **Phase 12: Comparison View** — Side-by-side tabular comparison with relative deltas (completed 2026-03-17)
|
- [x] **Phase 12: Comparison View** — Side-by-side tabular comparison with relative deltas (completed 2026-03-17)
|
||||||
- [ ] **Phase 13: Setup Impact Preview** — Per-candidate weight and cost delta against a selected setup
|
- [ ] **Phase 13: Setup Impact Preview** — Per-candidate weight and cost delta against a selected setup
|
||||||
|
|
||||||
|
### v2.0 Platform Foundation (Planned)
|
||||||
|
|
||||||
|
**Milestone Goal:** Transform GearBox from a single-user gear tracker into a multi-user platform where people discover gear, research purchases using crowd-verified data, and share their setups.
|
||||||
|
|
||||||
|
- [ ] **Phase 14: PostgreSQL Migration** — Replace SQLite with Postgres, make all operations async, establish new test infrastructure
|
||||||
|
- [ ] **Phase 15: External Authentication** — Integrate self-hosted OIDC auth provider for user registration and login
|
||||||
|
- [ ] **Phase 16: Multi-User Data Model** — Add user ownership to all entities with cross-user data isolation
|
||||||
|
- [ ] **Phase 17: Object Storage** — Move images from local filesystem to MinIO (S3-compatible)
|
||||||
|
- [ ] **Phase 18: Global Items & Public Profiles** — Global item catalog, user profiles, and public setup sharing
|
||||||
|
|
||||||
## Phase Details
|
## Phase Details
|
||||||
|
|
||||||
### Phase 10: Schema Foundation + Pros/Cons Fields
|
### Phase 10: Schema Foundation + Pros/Cons Fields
|
||||||
@@ -101,6 +112,65 @@ Plans:
|
|||||||
- [ ] 13-01-PLAN.md — TDD pure impact delta computation, uiStore state, ThreadWithCandidates type fix, useImpactDeltas hook
|
- [ ] 13-01-PLAN.md — TDD pure impact delta computation, uiStore state, ThreadWithCandidates type fix, useImpactDeltas hook
|
||||||
- [ ] 13-02-PLAN.md — SetupImpactSelector + ImpactDeltaBadge components, wire into thread detail and all candidate views
|
- [ ] 13-02-PLAN.md — SetupImpactSelector + ImpactDeltaBadge components, wire into thread detail and all candidate views
|
||||||
|
|
||||||
|
### Phase 14: PostgreSQL Migration
|
||||||
|
**Goal**: The application runs entirely on PostgreSQL with async operations, and all existing tests pass against the new database
|
||||||
|
**Depends on**: Phase 13
|
||||||
|
**Requirements**: DB-01, DB-02, DB-03, DB-04, DB-05
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. Application starts and serves all existing features using PostgreSQL as the sole database
|
||||||
|
2. All service-level and route-level tests pass using PGlite in-memory Postgres (no SQLite test infrastructure remains)
|
||||||
|
3. A one-time migration script converts existing SQLite data into the Postgres database without data loss
|
||||||
|
4. Docker Compose brings up Postgres alongside the app with a single command for local development
|
||||||
|
**Plans**: TBD
|
||||||
|
|
||||||
|
### Phase 15: External Authentication
|
||||||
|
**Goal**: Users can register and log in via a self-hosted OIDC auth provider, replacing the built-in single-user auth system
|
||||||
|
**Depends on**: Phase 14
|
||||||
|
**Requirements**: AUTH-01, AUTH-02, AUTH-03, AUTH-04, AUTH-05
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. A new user can register an account through the external auth provider and land on their empty GearBox dashboard
|
||||||
|
2. A returning user can log in via the auth provider and see their previously saved data
|
||||||
|
3. API keys continue to work for MCP tools and programmatic access without involving the auth provider
|
||||||
|
4. E2E tests run successfully using API key authentication, with no dependency on the external auth provider being available
|
||||||
|
5. The auth provider runs self-hosted in Docker Compose alongside Postgres and the application
|
||||||
|
**Plans**: TBD
|
||||||
|
|
||||||
|
### Phase 16: Multi-User Data Model
|
||||||
|
**Goal**: Every piece of user-created data is owned by a specific user, with complete isolation between users
|
||||||
|
**Depends on**: Phase 15
|
||||||
|
**Requirements**: MULTI-01, MULTI-02, MULTI-03, MULTI-04, MULTI-05, MULTI-06
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. User A cannot see or modify items, categories, threads, or setups created by User B
|
||||||
|
2. Two users can each have a category with the same name without conflict
|
||||||
|
3. Existing data from the single-user era is assigned to the original user account after migration
|
||||||
|
4. MCP tools return only data belonging to the authenticated API key's owner
|
||||||
|
5. Each user has independent settings (weight unit, onboarding state) that do not affect other users
|
||||||
|
**Plans**: TBD
|
||||||
|
|
||||||
|
### Phase 17: Object Storage
|
||||||
|
**Goal**: Images are stored in and served from MinIO instead of the local filesystem
|
||||||
|
**Depends on**: Phase 16
|
||||||
|
**Requirements**: IMG-01, IMG-02, IMG-03, IMG-04
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. Uploading an image for an item or candidate stores it in MinIO, not on the local filesystem
|
||||||
|
2. All previously uploaded images are accessible after migration to MinIO (no broken images)
|
||||||
|
3. Image URLs work correctly in all views (collection, planning, setups, comparison table)
|
||||||
|
4. Docker Compose includes MinIO for local development with no manual bucket setup required
|
||||||
|
**Plans**: TBD
|
||||||
|
|
||||||
|
### Phase 18: Global Items & Public Profiles
|
||||||
|
**Goal**: Users can discover gear through a global catalog and share their setups publicly via profile pages
|
||||||
|
**Depends on**: Phase 17
|
||||||
|
**Requirements**: GLOB-01, GLOB-02, GLOB-03, GLOB-04, GLOB-05, PROF-01, PROF-02, PROF-03, PROF-04, PROF-05
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. A global item catalog exists with brand, model, category, specs, and images, seeded with initial manufacturer data
|
||||||
|
2. User can search the global catalog by name or brand and link a personal collection item to a global entry
|
||||||
|
3. A global item page shows basic info and how many users own it
|
||||||
|
4. User can edit their profile (display name, avatar, bio) and view their own public profile page
|
||||||
|
5. User can toggle a setup between public and private; public setups are viewable by anyone without logging in and appear on the owner's public profile
|
||||||
|
**Plans**: TBD
|
||||||
|
**UI hint**: yes
|
||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
|
|
||||||
| Phase | Milestone | Plans Complete | Status | Completed |
|
| Phase | Milestone | Plans Complete | Status | Completed |
|
||||||
@@ -115,6 +185,11 @@ Plans:
|
|||||||
| 8. Search, Filter, and Candidate Status | v1.2 | 2/2 | Complete | 2026-03-16 |
|
| 8. Search, Filter, and Candidate Status | v1.2 | 2/2 | Complete | 2026-03-16 |
|
||||||
| 9. Weight Classification and Visualization | v1.2 | 2/2 | Complete | 2026-03-16 |
|
| 9. Weight Classification and Visualization | v1.2 | 2/2 | Complete | 2026-03-16 |
|
||||||
| 10. Schema Foundation + Pros/Cons Fields | v1.3 | 1/1 | Complete | 2026-03-16 |
|
| 10. Schema Foundation + Pros/Cons Fields | v1.3 | 1/1 | Complete | 2026-03-16 |
|
||||||
| 11. Candidate Ranking | 2/2 | Complete | 2026-03-16 | - |
|
| 11. Candidate Ranking | v1.3 | 2/2 | Complete | 2026-03-16 |
|
||||||
| 12. Comparison View | 1/1 | Complete | 2026-03-17 | - |
|
| 12. Comparison View | v1.3 | 1/1 | Complete | 2026-03-17 |
|
||||||
| 13. Setup Impact Preview | v1.3 | 0/2 | Not started | - |
|
| 13. Setup Impact Preview | v1.3 | 0/2 | Not started | - |
|
||||||
|
| 14. PostgreSQL Migration | v2.0 | 0/? | Not started | - |
|
||||||
|
| 15. External Authentication | v2.0 | 0/? | Not started | - |
|
||||||
|
| 16. Multi-User Data Model | v2.0 | 0/? | Not started | - |
|
||||||
|
| 17. Object Storage | v2.0 | 0/? | Not started | - |
|
||||||
|
| 18. Global Items & Public Profiles | v2.0 | 0/? | Not started | - |
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
---
|
---
|
||||||
gsd_state_version: 1.0
|
gsd_state_version: 1.0
|
||||||
milestone: v1.3
|
milestone: v2.0
|
||||||
milestone_name: Research & Decision Tools
|
milestone_name: Platform Foundation
|
||||||
status: planning
|
status: planning
|
||||||
stopped_at: Completed 12-comparison-view/12-01-PLAN.md
|
stopped_at: null
|
||||||
last_updated: "2026-03-17T14:35:39.075Z"
|
last_updated: "2026-04-03"
|
||||||
last_activity: 2026-03-16 — Roadmap created for v1.3 milestone
|
last_activity: 2026-04-03 — v2.0 roadmap created (Phases 14-18)
|
||||||
progress:
|
progress:
|
||||||
total_phases: 4
|
total_phases: 5
|
||||||
completed_phases: 3
|
completed_phases: 0
|
||||||
total_plans: 4
|
total_plans: 0
|
||||||
completed_plans: 4
|
completed_plans: 0
|
||||||
percent: 0
|
percent: 0
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -18,64 +18,40 @@ progress:
|
|||||||
|
|
||||||
## Project Reference
|
## Project Reference
|
||||||
|
|
||||||
See: .planning/PROJECT.md (updated 2026-03-16)
|
See: .planning/PROJECT.md (updated 2026-04-03)
|
||||||
|
|
||||||
**Core value:** Make it effortless to manage gear and plan new purchases -- see how a potential buy affects your total setup weight and cost before committing.
|
**Core value:** Help people make better gear decisions — discover what others use, compare real-world data, and see how a potential buy affects your setup before committing.
|
||||||
**Current focus:** v1.3 Research & Decision Tools — Phase 10 ready to plan
|
**Current focus:** v2.0 Platform Foundation — Phase 14 (PostgreSQL Migration)
|
||||||
|
|
||||||
## Current Position
|
## Current Position
|
||||||
|
|
||||||
Phase: 10 of 13 (Schema Foundation + Pros/Cons Fields)
|
Phase: 14 of 18 (PostgreSQL Migration)
|
||||||
Plan: —
|
Plan: 0 of ? in current phase
|
||||||
Status: Ready to plan
|
Status: Ready to plan
|
||||||
Last activity: 2026-03-16 — Roadmap created for v1.3 milestone
|
Last activity: 2026-04-03 — v2.0 roadmap created (Phases 14-18)
|
||||||
|
|
||||||
Progress: [░░░░░░░░░░] 0%
|
Progress: [----------] 0% (v2.0 milestone)
|
||||||
|
|
||||||
## Performance Metrics
|
## Performance Metrics
|
||||||
|
|
||||||
**Velocity:**
|
**Velocity:**
|
||||||
- Total plans completed: 0
|
- Total plans completed: 0 (v2.0 milestone)
|
||||||
- Average duration: —
|
- Average duration: --
|
||||||
- Total execution time: —
|
- Total execution time: --
|
||||||
|
|
||||||
**By Phase:**
|
|
||||||
|
|
||||||
| Phase | Plans | Total | Avg/Plan |
|
|
||||||
|-------|-------|-------|----------|
|
|
||||||
| - | - | - | - |
|
|
||||||
|
|
||||||
**Recent Trend:**
|
|
||||||
- Last 5 plans: —
|
|
||||||
- Trend: —
|
|
||||||
|
|
||||||
*Updated after each plan completion*
|
*Updated after each plan completion*
|
||||||
| Phase 10-schema-foundation-pros-cons-fields P01 | 6min | 2 tasks | 9 files |
|
|
||||||
| Phase 11-candidate-ranking P01 | 4min | 2 tasks | 8 files |
|
|
||||||
| Phase 11-candidate-ranking P02 | 4min | 3 tasks | 7 files |
|
|
||||||
| Phase 12-comparison-view P01 | 2min | 2 tasks | 3 files |
|
|
||||||
|
|
||||||
## Accumulated Context
|
## Accumulated Context
|
||||||
|
|
||||||
### Decisions
|
### Decisions
|
||||||
|
|
||||||
Cleared at milestone boundary. v1.2 decisions archived in milestones/v1.2-ROADMAP.md.
|
Key decisions made during v2.0 planning:
|
||||||
|
- Platform pivot: single-user to multi-user with discovery-first approach
|
||||||
Key v1.3 research findings (see research/SUMMARY.md):
|
- External auth provider (self-hosted, open-source) — Logto vs Authentik OPEN decision
|
||||||
- framer-motion@12.37.0 (already installed) handles drag-to-reorder via Reorder component — no new deps
|
- SQLite to Postgres migration — required by auth provider and multi-user concurrency
|
||||||
- sort_order must use REAL (float) type, not INTEGER, to avoid bulk writes on every drag
|
- Structured UGC only — ratings and predefined fields, no freeform text until moderation
|
||||||
- Impact preview must distinguish add-mode vs replace-mode by category match — pure addition misleads
|
- Separate globalItems table — not a flag on user items table
|
||||||
- [Phase 10-schema-foundation-pros-cons-fields]: Empty string for pros/cons stored as-is (not normalized to null); test accepts either empty string or null as cleared state
|
- Single-user SQLite mode diverges at v2.0 boundary
|
||||||
- [Phase 10-schema-foundation-pros-cons-fields]: Pros/Cons badge uses purple color to distinguish from weight (blue), price (green), category (gray), and status badges
|
|
||||||
- [Phase 10-schema-foundation-pros-cons-fields]: Field-addition ladder pattern: schema -> migration -> test helper -> service -> Zod -> types -> hook -> form -> card indicator
|
|
||||||
- [Phase 11-candidate-ranking]: sortOrder uses REAL type for future fractional midpoint insertions without bulk rewrites
|
|
||||||
- [Phase 11-candidate-ranking]: 1000-gap sort_order strategy: first=1000, append=max+1000, reorder resets to (index+1)*1000
|
|
||||||
- [Phase 11-candidate-ranking]: Applied sort_order migration via sqlite3 CLI directly to avoid Drizzle data-loss warning on existing rows
|
|
||||||
- [Phase 11-candidate-ranking]: Resolved thread list view uses plain div (not Reorder.Group) — no drag, rank badges visible
|
|
||||||
- [Phase 11-candidate-ranking]: RankBadge exported from CandidateListItem for reuse in CandidateCard grid view
|
|
||||||
- [Phase 12-comparison-view]: ATTRIBUTE_ROWS declarative array pattern for ComparisonTable keeps JSX clean and row reordering trivial
|
|
||||||
- [Phase 12-comparison-view]: Compare toggle only shown for 2+ candidates; Add Candidate hidden in compare view (read-only intent)
|
|
||||||
- [Phase 12-comparison-view]: Weight/price highlight color takes priority over amber winner tint when both apply (more informative)
|
|
||||||
|
|
||||||
### Pending Todos
|
### Pending Todos
|
||||||
|
|
||||||
@@ -83,10 +59,11 @@ None active.
|
|||||||
|
|
||||||
### Blockers/Concerns
|
### Blockers/Concerns
|
||||||
|
|
||||||
None active.
|
- Auth provider decision (Logto vs Authentik) must be resolved before Phase 15 planning
|
||||||
|
- Phase 14 is a full schema rewrite touching 6 services, 7 routes, 19 MCP tools, all tests
|
||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-03-17T14:32:04.702Z
|
Last session: 2026-04-03
|
||||||
Stopped at: Completed 12-comparison-view/12-01-PLAN.md
|
Stopped at: v2.0 roadmap created with 5 phases (14-18) covering 30 requirements
|
||||||
Resume file: None
|
Resume file: None
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,28 @@
|
|||||||
# Feature Research
|
# Feature Research
|
||||||
|
|
||||||
**Domain:** Gear management — candidate comparison, setup impact preview, and candidate ranking
|
**Domain:** Multi-user gear management and discovery platform
|
||||||
**Researched:** 2026-03-16
|
**Researched:** 2026-04-03
|
||||||
**Confidence:** HIGH (existing codebase fully understood; UX patterns verified via multiple sources)
|
**Confidence:** MEDIUM-HIGH
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
This is a subsequent milestone research file for **v1.3 Research & Decision Tools**.
|
This is the feature research for **v2.0 Platform Foundation** -- transforming GearBox from a single-user gear tracker into a multi-user platform with discovery, global item database, structured reviews, and setup sharing.
|
||||||
The features below are **additive** to v1.2. All three features operate within the existing
|
|
||||||
`threads/$threadId` page and its data model.
|
|
||||||
|
|
||||||
**Existing data model relevant to this milestone:**
|
**Existing features (already built through v1.4):**
|
||||||
- `threadCandidates`: id, threadId, name, weightGrams, priceCents, categoryId, notes, productUrl, imageFilename, status — no rank, pros, or cons columns yet
|
- Gear collection CRUD with categories, weight/price, images, quantity
|
||||||
- `setups` + `setupItems`: stores weight/cost per setup item with classification (base/worn/consumable)
|
- Planning threads with candidate comparison, ranking, pros/cons, impact preview
|
||||||
- `getSetupWithItems` already returns `classification` per item — available for impact preview
|
- Named setups (loadouts) with classification, donut chart visualization
|
||||||
|
- Search/filter, CSV import/export, item duplication
|
||||||
|
- Dashboard home page, onboarding wizard
|
||||||
|
- Single-user auth (cookie sessions + API keys), MCP server (19 tools)
|
||||||
|
|
||||||
|
**Key project constraints:**
|
||||||
|
- No freeform UGC until moderation infrastructure exists (structured input only)
|
||||||
|
- Discovery-first, not social-first
|
||||||
|
- External auth provider (self-hosted, open-source)
|
||||||
|
- Postgres for multi-user platform
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -23,119 +30,150 @@ The features below are **additive** to v1.2. All three features operate within t
|
|||||||
|
|
||||||
### Table Stakes (Users Expect These)
|
### Table Stakes (Users Expect These)
|
||||||
|
|
||||||
Features users assume exist in any comparison or decision tool. Missing these makes the thread
|
Features users assume exist on any multi-user gear platform. Missing these makes the platform feel broken or pointless.
|
||||||
detail page feel incomplete as a decision workspace.
|
|
||||||
|
|
||||||
| Feature | Why Expected | Complexity | Notes |
|
| Feature | Why Expected | Complexity | Notes |
|
||||||
|---------|--------------|------------|-------|
|
|---------|--------------|------------|-------|
|
||||||
| Side-by-side comparison view | Any comparison tool in any domain shows attributes aligned per-column. Card grid (current) forces mental juggling between candidates. E-commerce, spec sheets, gear apps — all use tabular layout for comparison. | MEDIUM | Rows = attributes (image, name, weight, price, status, notes, link), columns = candidates. Sticky attribute-label column during horizontal scroll. Max 3–4 candidates usable on desktop; 2 on mobile. Toggle between grid view (current) and table view. |
|
| **User registration and authentication** | Cannot have multi-user without accounts. Every platform has sign-up/login. | HIGH | External auth provider integration (Authentik, Keycloak, or similar). Replaces current single-user cookie auth. All existing entities need userId FK. |
|
||||||
| Weight delta per candidate | Gear apps (LighterPack, GearGrams) display weight totals prominently. Users replacing an item need the delta, not just the raw weight of the candidate. | LOW | Pure client-side computation: `candidate.weightGrams - existingItemWeight`. No API call needed if setup data already loaded via `useSetup`. |
|
| **User profiles (public)** | Every community platform has profiles. Users need identity to share and be discovered. | LOW | Minimal: display name, avatar URL, bio text, joined date. Public profile page lists user's public setups. No follower counts needed. |
|
||||||
| Cost delta per candidate | Same reasoning as weight delta. A purchase decision is always the weight vs. cost tradeoff. | LOW | Same pattern as weight delta. Color-coded: green for savings/lighter, red for more expensive/heavier. |
|
| **Setup visibility controls** | Users will not share setups if they cannot control what is public. Privacy is table stakes for any sharing platform. | LOW | Binary public/private toggle per setup. Default to private (opt-in sharing). Existing setups migrated as private. |
|
||||||
| Setup selector for impact preview | User needs to pick which setup to compute deltas against — not all setups contain the same category of item being replaced. | MEDIUM | Dropdown of setup names populated from `useSetups()`. When selected, loads setup via `useSetup(id)`. "No setup selected" state shows raw candidate values only, no delta. |
|
| **Public setup detail pages** | Shared setup links must resolve to a readable page. If sharing is a feature, the shared thing must be viewable. | MEDIUM | Read-only view with item list, weight/cost totals, donut chart, creator attribution. No auth required for public setups. Extends existing setup detail view. |
|
||||||
|
| **Global item database (searchable)** | Users expect to find gear by name rather than entering specs from scratch every time. LighterPack's weakness is fully manual data entry. | HIGH | Central product catalog with brand, model, category, manufacturer weight, MSRP, product URL, image. Users search and link rather than re-enter. Seed with 200-500 items in core categories to bootstrap. This is the foundational dependency for reviews, aggregation, and item detail pages. |
|
||||||
|
| **Link personal items to global items** | Once a global DB exists, users expect to connect their gear to canonical entries for richer data. | MEDIUM | Optional FK from user items to global items. Enables aggregation (owner count, avg weight, reviews). Must handle items not yet in global DB gracefully. |
|
||||||
|
| **Item detail page (aggregated)** | When browsing gear, clicking an item should show consolidated info: specs, who owns it, ratings. Standard on any product platform. | HIGH | Aggregated view combining: manufacturer specs from global DB, owner count, setup appearances, average ratings, crowd-reported weights. This is the integration hub for all platform features. |
|
||||||
|
| **Structured reviews (ratings)** | Any product-oriented community needs evaluation. Users expect to rate gear and see what others think. | MEDIUM | Overall 1-5 star rating plus 3-5 dimension ratings (varies by product category). Attached to global items, not personal items. One review per user per global item. No freeform text per project constraint. |
|
||||||
|
| **Discovery browse page** | Users expect a way to find interesting setups and gear beyond their own collection. Without this, multi-user adds no value. | MEDIUM | Not algorithmic for v2.0. Three sections: recent public setups, recently reviewed items, popular gear (most owned). Simple sorted lists with pagination. |
|
||||||
|
| **Search global items** | Must be able to find products by name/brand in the global database. Powers linking, browsing, and review discovery. | MEDIUM | Full-text search on name, brand, category. Used in "link my item" flow, discovery browsing, and review lookup. Postgres full-text search or trigram index. |
|
||||||
|
|
||||||
### Differentiators (Competitive Advantage)
|
### Differentiators (Competitive Advantage)
|
||||||
|
|
||||||
Features not found in LighterPack, GearGrams, or any other gear app. Directly serve the
|
Features that set GearBox apart from LighterPack, GearGrams, Trailspace, and MyGear. Aligned with core value: "help people make better gear decisions."
|
||||||
"decide between candidates" workflow that is unique to GearBox.
|
|
||||||
|
|
||||||
| Feature | Value Proposition | Complexity | Notes |
|
| Feature | Value Proposition | Complexity | Notes |
|
||||||
|---------|-------------------|------------|-------|
|
|---------|-------------------|------------|-------|
|
||||||
| Drag-to-rank ordering | Makes priority explicit without a numeric input. Ranking communicates "this is my current top pick." Maps to how users mentally stack-rank options during research. No competitor has this in the gear domain. | MEDIUM | `@dnd-kit/sortable` is the current standard (actively maintained; `react-beautiful-dnd` is abandoned as of 2025). Requires new `rank` integer column on `threadCandidates`. Persist order via PATCH endpoint. |
|
| **Crowd-verified specs** | LighterPack trusts user-entered data blindly. GearBox can show "manufacturer says 450g, 12 owners measured avg 478g." Real-world weight verification is unique and high-value for weight-conscious users. | MEDIUM | Aggregate weightGrams from all user items linked to a global item. Compare against manufacturer spec. Display on item detail page. Needs sufficient linked items to be meaningful (threshold: 3+ owners). |
|
||||||
| Per-candidate pros/cons fields | Freeform text capturing the reasoning behind ranking. LighterPack and GearGrams have notes per item but no structured decision rationale. Differentiates GearBox as a decision tool, not just a list tracker. | LOW | Two textarea fields per candidate. New `pros` and `cons` text columns on `threadCandidates`. Visible in comparison view rows and candidate edit panel. |
|
| **Review dimensions per product category** | Trailspace and OutdoorGearLab use editorial ratings with fixed dimensions. GearBox crowd-sources structured ratings with category-specific dimensions: a tent gets "weather protection, ventilation, setup ease" while a stove gets "boil time, fuel efficiency, packability." More relevant than one-size-fits-all. | MEDIUM | Define 3-5 rating dimensions per product category via admin config. Store dimension ratings alongside overall rating. Display as radar chart or bar chart on item detail page. |
|
||||||
| Impact preview with category-matched delta | Setup items have a category. The most meaningful delta is weight saved within the same category (e.g., comparing sleeping pads, subtract current sleeping pad weight from setup total). More actionable than comparing against the entire setup total. | MEDIUM | Use `candidate.categoryId` to find matching setup items and compute delta. Edge case: no item of that category in the setup → show "not in setup." Data already available from `getSetupWithItems`. |
|
| **"X people own this" social proof** | Shows popularity and real adoption. No gear tracker does this because they lack a global item database. Simple count, powerful signal. | LOW | Count of users who linked a collection item to this global item. Displayed prominently on item detail page and in search results. Zero implementation complexity once linking exists. |
|
||||||
|
| **Setup composition insights** | "This item appears in 47 bikepacking setups, commonly paired with Y and Z." Cross-setup analysis no competitor offers. Answers "what do people use this with?" | MEDIUM | Query across all public setups containing a given global item. Show co-occurrence patterns. Powerful but can be deferred to v2.x if query performance is a concern. |
|
||||||
|
| **Setup impact preview with global items** | Already built for personal items. Extending to global items lets users preview "adding this from the store to my setup changes weight by X." Bridges research and collection management. | LOW | Already exists for personal items. Add "preview in my setup" button on global item detail pages. Reuse existing impact preview logic. |
|
||||||
|
| **Planning threads with global item integration** | Research threads that pull in specs, reviews, and owner data from the global DB. Candidates link to global items for richer comparison than manual data entry. | MEDIUM | Add optional globalItemId to thread candidates. Auto-populate weight, price, image from global item. Show community ratings and owner count inline on candidates. |
|
||||||
|
| **Real-world weight distribution** | Histogram showing "owners report weights between 440g-490g" for a product. Beats a single manufacturer number. Valuable for ultralight community. | LOW | Aggregate weightGrams from all linked items. Display min/max/avg. Histogram if 10+ data points. |
|
||||||
|
| **Copy/fork public setups** | Use someone else's setup as a starting template. LighterPack has clunky CSV-based copying. One-click fork is much better UX. | LOW | Create new setup copying all items from a public setup. Items must exist in user's collection (or be linked to same global items). Clear UX for "items you do not own yet." |
|
||||||
|
|
||||||
### Anti-Features (Commonly Requested, Often Problematic)
|
### Anti-Features (Commonly Requested, Often Problematic)
|
||||||
|
|
||||||
| Feature | Why Requested | Why Problematic | Alternative |
|
| Feature | Why Requested | Why Problematic | Alternative |
|
||||||
|---------|---------------|-----------------|-------------|
|
|---------|---------------|-----------------|-------------|
|
||||||
| Custom comparison attributes | "I want to compare battery life, durability, color..." | PROJECT.md explicitly rejects this as a complexity trap. Custom attributes require schema generalization, dynamic rendering, and data entry friction for every candidate. | Notes field and pros/cons fields cover the remaining use cases. |
|
| **Freeform text reviews** | Users want to explain their experience in detail | Requires moderation, spam filtering, content policy, reporting infrastructure. PROJECT.md explicitly defers until moderation exists. | Structured ratings with predefined dimensions. Short predefined tags for pros/cons (e.g., "lightweight", "durable", "runs small"). |
|
||||||
| Score/rating calculation | Automatically rank candidates by computed score | Score algorithms require encoding the user's weight-vs-price preference — personalization complexity. Users distrust opaque scores. | Manual drag-to-rank expresses the user's own weighting without encoding it in an algorithm. |
|
| **Comments on setups** | Social engagement, questions about gear choices | Moderation burden, notification system, spam, harassment risk. Deferred in PROJECT.md. | Link to user profile. Contact happens outside platform. |
|
||||||
| Side-by-side comparison across threads | Compare candidates from different research threads | Candidates belong to different purchase decisions — mixing them is conceptually incoherent. Different categories are never apples-to-apples. | Thread remains the scope boundary. Cross-thread planning is what setups are for. |
|
| **Follow users / activity feed** | Social graph, staying updated on people | Turns a gear tool into a social network. Notification infrastructure, feed ranking, engagement metrics, retention loops. Project decision: discovery-first, not social-first. | Discovery feed shows popular/recent content without requiring social connections. |
|
||||||
| Comparison permalink/share | Share a comparison view URL | GearBox is single-user, no auth for v1. Sharing requires auth, user management, public/private visibility. | Out of scope for v1 per PROJECT.md. Future feature. |
|
| **Marketplace / buy-sell** | Users want to trade used gear | Payment processing, fraud prevention, disputes, shipping logistics, tax compliance. Massive liability. | Link to product URLs on global items. Users buy through retailers. |
|
||||||
| Classification-aware impact preview as MVP requirement | Show delta broken down by base/worn/consumable | While data is available, the classification breakdown adds significant UI complexity. The flat delta answers "will this make my setup lighter?" which is 90% of the use case. | Flat delta for MVP. Classification-aware breakdown as a follow-up enhancement (P2). |
|
| **AI gear recommendations** | "What tent should I buy for bikepacking?" | Training data requirements, bias, liability for bad recommendations, hallucination risk. | Global item pages with ratings, owner counts, and setup co-occurrence do implicit recommendation. "People who own X also own Y." |
|
||||||
|
| **Wiki-style open item editing** | Community wants to correct/enrich global item specs | Edit wars, vandalism, quality degradation, dispute resolution. PROJECT.md explicitly rules this out. | Structured contributions only: report measured weight, submit rating. Admin approval for spec corrections. Trusted contributor program later. |
|
||||||
|
| **Price tracking / deal alerts** | Users want to know when gear goes on sale | Requires scraping retailer sites, fragile, legal gray area, maintenance burden. PROJECT.md rules this out. | Store product URL so users can check prices manually. |
|
||||||
|
| **Real-time collaborative setups** | "Plan a group trip together" | WebSocket infrastructure, conflict resolution, permissions model, presence indicators. Massive complexity for niche use case. | Each user builds their own setup. Fork public setups as templates. |
|
||||||
|
| **Gamification (badges, points, levels)** | Drive engagement and contributions | Incentivizes quantity over quality. Users game systems for points rather than providing genuine data. Creates toxic dynamics. | Soft social proof: "contributed X reviews" on profile. No points, no leaderboards. |
|
||||||
|
| **Instagram-style infinite scroll feed** | Addictive browsing experience | Engagement-maximizing design conflicts with utility-focused tool. Users come to research decisions, not scroll endlessly. | Paginated, filterable discovery page. Browse with intent, not addiction. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Feature Dependencies
|
## Feature Dependencies
|
||||||
|
|
||||||
```
|
```
|
||||||
[Side-by-side comparison view]
|
[External Auth Provider]
|
||||||
└──requires──> [All candidate fields visible in UI]
|
|
|
||||||
(weightGrams, priceCents, notes, productUrl already in schema)
|
v
|
||||||
└──enhances──> [Pros/cons fields] (displayed as comparison rows)
|
[Multi-User Data Model (userId FK on all entities)]
|
||||||
└──enhances──> [Drag-to-rank] (rank number shown as position in comparison columns)
|
|
|
||||||
└──enhances──> [Impact preview] (delta displayed per-column inline)
|
+---> [Postgres Migration] (concurrent access, auth provider needs Postgres)
|
||||||
|
|
|
||||||
[Impact preview (weight + cost delta)]
|
+---> [User Profiles (public)]
|
||||||
└──requires──> [Setup selector] (user picks which setup to compute delta against)
|
| |
|
||||||
└──requires──> [Setup data client-side] (useSetup hook already exists, no new API)
|
| +---> [Public Profile Pages]
|
||||||
└──requires──> [Candidate weight/price data] (already in threadCandidates schema)
|
| | |
|
||||||
|
| | +---> [Discovery Feed (browse users' public content)]
|
||||||
[Setup selector]
|
| |
|
||||||
└──requires──> [useSetups() hook] (already exists in src/client/hooks/useSetups.ts)
|
| +---> [Setup Visibility Controls (public/private)]
|
||||||
└──requires──> [useSetup(id) hook] (already exists, loads items with classification)
|
| |
|
||||||
|
| +---> [Public Setup Detail Pages]
|
||||||
[Drag-to-rank]
|
| |
|
||||||
└──requires──> [rank INTEGER column on threadCandidates] (new — schema migration)
|
| +---> [Copy/Fork Public Setups]
|
||||||
└──requires──> [PATCH /api/threads/:id/candidates/rank endpoint] (new API endpoint)
|
|
|
||||||
└──enhances──> [Side-by-side comparison] (rank visible as position indicator)
|
+---> [Global Item Database]
|
||||||
└──enhances──> [Card grid view] (rank badge on each CandidateCard)
|
|
|
||||||
|
+---> [Search Global Items]
|
||||||
[Pros/cons fields]
|
|
|
||||||
└──requires──> [pros TEXT column on threadCandidates] (new — schema migration)
|
+---> [Link Personal Items to Global Items]
|
||||||
└──requires──> [cons TEXT column on threadCandidates] (new — schema migration)
|
| |
|
||||||
└──requires──> [updateCandidateSchema extended] (add pros/cons to Zod schema)
|
| +---> [Owner Count ("X people own this")]
|
||||||
└──enhances──> [CandidateForm edit panel] (new textarea fields)
|
| |
|
||||||
└──enhances──> [Side-by-side comparison] (pros/cons rows in comparison table)
|
| +---> [Crowd-Verified Specs (aggregated weight)]
|
||||||
|
| |
|
||||||
|
| +---> [Setup Appearances Count]
|
||||||
|
| |
|
||||||
|
| +---> [Real-World Weight Distribution]
|
||||||
|
|
|
||||||
|
+---> [Structured Reviews]
|
||||||
|
| |
|
||||||
|
| +---> [Review Dimensions per Category]
|
||||||
|
| |
|
||||||
|
| +---> [Average Ratings Display]
|
||||||
|
|
|
||||||
|
+---> [Item Detail Pages (aggregated hub)]
|
||||||
|
| |
|
||||||
|
| +---> [Setup Composition Insights]
|
||||||
|
|
|
||||||
|
+---> [Planning Thread Global Item Integration]
|
||||||
|
|
|
||||||
|
+---> [Candidate Auto-populate from Global DB]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Dependency Notes
|
### Dependency Notes
|
||||||
|
|
||||||
- **Side-by-side comparison is independent of schema changes.** It can be built using
|
- **Multi-user data model is the absolute foundation.** Every feature depends on userId ownership. Items, setups, threads, categories, reviews -- all need user scoping. This is the biggest single migration.
|
||||||
existing candidate data. No migrations required. Delivers value immediately.
|
- **Postgres migration is coupled with auth.** The external auth provider (Authentik, Keycloak) needs Postgres. Migrating the app DB at the same time avoids running two databases. Do these together.
|
||||||
- **Impact preview is independent of schema changes.** Uses existing `useSetups` and
|
- **Global item database is the second foundation.** Reviews, item detail pages, owner counts, crowd-verified specs, and planning thread integration all depend on canonical global item records. Without this, multi-user is just "LighterPack with accounts."
|
||||||
`useSetup` hooks client-side. Delta computation is pure math in the component.
|
- **Structured reviews require global items.** Reviews attach to global items, not personal collection items. Otherwise reviews fragment across duplicate user-entered items with no way to aggregate.
|
||||||
No new API endpoint needed for MVP.
|
- **Item detail pages are the integration point.** They combine global item specs, aggregated user data, reviews, owner count, and setup appearances. Should be built after all data sources exist.
|
||||||
- **Drag-to-rank requires schema migration.** `rank` column must be added to
|
- **Discovery feed requires profiles + public content.** Cannot browse without user identity and visibility controls producing public content to show.
|
||||||
`threadCandidates`. Default ordering on migration = `createdAt` ascending.
|
- **Linking is the bridge.** Personal items link to global items. This single FK enables owner count, crowd-verified specs, weight distribution, and setup appearances. Prioritize this flow.
|
||||||
- **Pros/cons requires schema migration.** Two nullable `text` columns on
|
|
||||||
`threadCandidates`. Low risk — nullable, backwards compatible.
|
|
||||||
- **Comparison view enhances everything.** Best delivered after rank and pros/cons
|
|
||||||
schema work is done so the full table is useful from day one.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## MVP Definition
|
## MVP Definition
|
||||||
|
|
||||||
### Launch With (v1.3 milestone)
|
### Launch With (v2.0 Platform Foundation)
|
||||||
|
|
||||||
- [ ] **Side-by-side comparison view** — Core deliverable. Replace mental juggling of the card
|
- [ ] **External auth provider integration** -- Nothing works without multi-user identity
|
||||||
grid with a scannable table. No schema changes. Highest ROI, lowest risk.
|
- [ ] **Postgres migration** -- Required for concurrent access; auth provider dependency
|
||||||
- [ ] **Impact preview: flat weight + cost delta per candidate** — Shows `+/- X g` and
|
- [ ] **Multi-user data model** -- userId on items, setups, threads, categories; data isolation
|
||||||
`+/- $Y` vs. the selected setup. Pure client-side math. No schema changes.
|
- [ ] **User profiles (minimal)** -- Display name, avatar, bio; public profile page
|
||||||
- [ ] **Setup selector** — Dropdown of user's setups. Required for impact preview. One
|
- [ ] **Setup visibility controls** -- Public/private toggle, default private
|
||||||
interaction: pick a setup, see deltas update.
|
- [ ] **Public setup detail pages** -- Shareable read-only view with attribution
|
||||||
- [ ] **Drag-to-rank** — Requires `rank` column migration. `@dnd-kit/sortable` handles
|
- [ ] **Global item database with seed data** -- Schema, admin seeding, search
|
||||||
the drag UX. Persist via new PATCH endpoint.
|
- [ ] **Link personal items to global items** -- Association flow in collection UI
|
||||||
- [ ] **Pros/cons text fields** — Requires `pros` + `cons` column migration. Trivially low
|
- [ ] **Structured reviews** -- Overall rating + dimension ratings on global items
|
||||||
implementation complexity once schema is in place.
|
- [ ] **Item detail pages** -- Aggregated specs, owner count, average ratings
|
||||||
|
- [ ] **Discovery browse page** -- Recent public setups, recently reviewed, popular items
|
||||||
|
|
||||||
### Add After Validation (v1.x)
|
### Add After Validation (v2.x)
|
||||||
|
|
||||||
- [ ] **Classification-aware impact preview** — Delta broken down by base/worn/consumable.
|
- [ ] **Crowd-verified specs display** -- "Manufacturer: 450g, Community avg: 478g" (needs 3+ owners per item to be meaningful)
|
||||||
Higher complexity UI. Add once flat delta is validated as useful.
|
- [ ] **Setup composition insights** -- "Commonly paired with" co-occurrence analysis
|
||||||
Trigger: user feedback requests "which classification does this affect?"
|
- [ ] **Planning thread global item integration** -- Candidates auto-populate from global DB
|
||||||
- [ ] **Rank indicator on card grid** — Small "1st", "2nd" badge on CandidateCard.
|
- [ ] **Popular gear rankings by category** -- Most owned, highest rated per category
|
||||||
Trigger: users express confusion about which candidate is ranked first without entering
|
- [ ] **Copy/fork public setups** -- One-click template from public setups
|
||||||
comparison view.
|
- [ ] **Review dimension customization** -- Admin configures rating dimensions per product category
|
||||||
- [ ] **Comparison view on mobile** — Horizontal scroll works but is not ideal. Consider
|
- [ ] **Real-world weight distribution** -- Histogram on item detail pages
|
||||||
attribute-focus swipe view. Trigger: usage data shows mobile traffic on thread pages.
|
- [ ] **Global item suggestion workflow** -- Users propose new items for admin review
|
||||||
|
|
||||||
### Future Consideration (v2+)
|
### Future Consideration (v3+)
|
||||||
|
|
||||||
- [ ] **Comparison permalink** — Requires auth/multi-user work first.
|
- [ ] **Freeform reviews with moderation** -- After moderation infrastructure exists
|
||||||
- [ ] **Auto-fill from product URL** — Fragile scraping, rejected in PROJECT.md.
|
- [ ] **Comments on setups** -- After moderation infrastructure exists
|
||||||
- [ ] **Custom comparison attributes** — Explicitly rejected in PROJECT.md.
|
- [ ] **Follow users / activity feed** -- After discovery model is validated
|
||||||
|
- [ ] **OAuth / social login** -- After external auth provider is stable
|
||||||
|
- [ ] **Trusted contributor program** -- Verified users can edit global item specs
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -143,120 +181,122 @@ Features not found in LighterPack, GearGrams, or any other gear app. Directly se
|
|||||||
|
|
||||||
| Feature | User Value | Implementation Cost | Priority |
|
| Feature | User Value | Implementation Cost | Priority |
|
||||||
|---------|------------|---------------------|----------|
|
|---------|------------|---------------------|----------|
|
||||||
| Side-by-side comparison view | HIGH | MEDIUM | P1 |
|
| External auth provider | HIGH | HIGH | P1 |
|
||||||
| Setup impact preview (flat delta) | HIGH | LOW | P1 |
|
| Postgres migration | HIGH | HIGH | P1 |
|
||||||
| Setup selector for impact preview | HIGH | LOW | P1 |
|
| Multi-user data model (userId on entities) | HIGH | HIGH | P1 |
|
||||||
| Drag-to-rank ordering | MEDIUM | MEDIUM | P1 |
|
| User profiles (basic) | HIGH | LOW | P1 |
|
||||||
| Pros/cons text fields | MEDIUM | LOW | P1 |
|
| Setup visibility controls | HIGH | LOW | P1 |
|
||||||
| Classification-aware impact preview | MEDIUM | HIGH | P2 |
|
| Public setup detail pages | HIGH | MEDIUM | P1 |
|
||||||
| Rank indicator on card grid | LOW | LOW | P2 |
|
| Global item database (schema + seed) | HIGH | HIGH | P1 |
|
||||||
| Mobile-optimized comparison view | LOW | MEDIUM | P3 |
|
| Link personal items to global items | HIGH | MEDIUM | P1 |
|
||||||
|
| Search global items | HIGH | MEDIUM | P1 |
|
||||||
|
| Structured reviews | HIGH | MEDIUM | P1 |
|
||||||
|
| Item detail pages (aggregated) | HIGH | HIGH | P1 |
|
||||||
|
| Discovery browse page | MEDIUM | MEDIUM | P1 |
|
||||||
|
| Crowd-verified specs | HIGH | LOW | P2 |
|
||||||
|
| Setup composition insights | MEDIUM | MEDIUM | P2 |
|
||||||
|
| Planning thread global DB integration | MEDIUM | MEDIUM | P2 |
|
||||||
|
| Copy/fork public setups | MEDIUM | LOW | P2 |
|
||||||
|
| Popular gear rankings | MEDIUM | LOW | P2 |
|
||||||
|
| Freeform reviews + moderation | MEDIUM | HIGH | P3 |
|
||||||
|
| Follow users | LOW | MEDIUM | P3 |
|
||||||
|
| Setup comments | LOW | MEDIUM | P3 |
|
||||||
|
|
||||||
**Priority key:**
|
**Priority key:**
|
||||||
- P1: Must have for this milestone launch
|
- P1: Must have for v2.0 platform launch
|
||||||
- P2: Should have, add when possible
|
- P2: Should have, add in v2.x once core is validated
|
||||||
- P3: Nice to have, future consideration
|
- P3: Future consideration, requires new infrastructure (moderation, notifications)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Competitor Feature Analysis
|
## Competitor Feature Analysis
|
||||||
|
|
||||||
| Feature | LighterPack | GearGrams | OutPack | Our Approach |
|
| Feature | LighterPack | GearGrams | Trailspace | MyGear | GearBox v2.0 |
|
||||||
|---------|-------------|-----------|---------|--------------|
|
|---------|-------------|-----------|------------|--------|-------------|
|
||||||
| Side-by-side candidate comparison | None (list only) | None (library + trip list) | None | Inline comparison table on thread detail page, toggle from grid view |
|
| Gear lists/setups | Yes, drag-and-drop | Yes, trip-based | No (review only) | Yes, "Locker" | Yes, named setups with classification |
|
||||||
| Impact preview / weight delta | None (duplicate lists manually to compare) | None (no delta concept) | None | Per-candidate delta vs. selected setup, computed client-side |
|
| Weight tracking | Base/worn/consumable | Carried/worn/consumable | No | Basic | Base/worn/consumable + unit conversion + donut charts |
|
||||||
| Candidate ranking | None | None | None | Drag-to-rank with persisted `rank` column |
|
| User profiles | Minimal (no bio) | Minimal | Review history page | Full social profile | Display name, avatar, bio, public setups |
|
||||||
| Pros/cons annotation | None (notes field only) | None (notes field only) | None | Dedicated `pros` and `cons` fields separate from general notes |
|
| Sharing | Public link, embed code | Public link | N/A | Social feed posts | Public/private toggle, shareable URLs |
|
||||||
| Status tracking | None | "wish list" item flag only | None | Already built in v1.2 (researching/ordered/arrived) |
|
| Global item database | No (all user-entered) | No | Yes (editorial catalog) | No | Yes, seeded + crowd-enriched with verified specs |
|
||||||
|
| Structured reviews | No | No | Yes (summary/pros/cons + rating) | Basic star rating | Dimension ratings per product category |
|
||||||
|
| Item aggregation | No | No | Editorial scores only | No | Owner count, avg weight, setup appearances, crowd specs |
|
||||||
|
| Discovery/browse | No | No | Browse by category | AI-tagged social feed | Browse setups, items, popular gear (intent-driven, not feed) |
|
||||||
|
| Purchase research | No | No | Price comparison links | No | Planning threads with candidates, ranking, impact preview |
|
||||||
|
| Crowd-verified specs | No | No | No | No | Manufacturer vs. community-measured weight comparison |
|
||||||
|
| Mobile app | No | Yes (iOS/Android) | No | Yes (iOS/Android) | No (responsive web, per project constraint) |
|
||||||
|
|
||||||
**Key insight:** No existing gear management tool has a comparison view, delta preview, or
|
### Competitive Positioning
|
||||||
ranking system for candidates within a research thread. This is an unmet-need gap.
|
|
||||||
The features are adapted from general product comparison UX (e-commerce) to the gear domain.
|
GearBox occupies a unique niche: the only platform combining **gear management** (LighterPack's strength), **structured community reviews** (Trailspace's strength), and **crowd-verified specs** (nobody does this). The planning threads feature has no direct competitor equivalent in the gear domain.
|
||||||
|
|
||||||
|
**Key advantages over each competitor:**
|
||||||
|
- **vs. LighterPack:** Global item database eliminates manual spec entry. Multi-user with profiles and sharing. Structured reviews provide community intelligence.
|
||||||
|
- **vs. GearGrams:** Richer comparison tools (planning threads). Crowd-verified specs. Item detail pages with aggregated data.
|
||||||
|
- **vs. Trailspace:** Not just reviews -- full gear management and setup composition. Users own and track their gear, not just review it. Crowd ratings, not editorial-only.
|
||||||
|
- **vs. MyGear:** Not social-first (no engagement loops, no AI tagging gimmicks). Utility-focused: research decisions, verify specs, compare options. Hobby-agnostic data model.
|
||||||
|
|
||||||
|
**Accepted gaps:**
|
||||||
|
- No mobile native app (web-first, responsive design sufficient per project constraints)
|
||||||
|
- No social feed in the Instagram sense (intentional: discovery-first, not social-first)
|
||||||
|
- No freeform text content (intentional: structured input only until moderation exists)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Implementation Notes by Feature
|
## Implementation Notes for Key Features
|
||||||
|
|
||||||
### Side-by-side Comparison View
|
### Global Item Database Schema
|
||||||
|
|
||||||
- Rendered as a transposed table: rows = attribute labels, columns = candidates.
|
The global item table is distinct from user items. It represents canonical products:
|
||||||
- Rows: Image (thumbnail), Name, Weight, Price, Status, Notes, Link, Pros, Cons, Rank, Impact Delta (weight), Impact Delta (cost).
|
|
||||||
- Sticky first column (attribute label) while candidate columns scroll horizontally for 3+.
|
|
||||||
- Candidate images at reduced aspect ratio (square thumbnail ~80px).
|
|
||||||
- Weight/price cells use existing `formatWeight` / `formatPrice` formatters with the user's preferred unit.
|
|
||||||
- Status cell reuses existing `StatusBadge` component.
|
|
||||||
- "Pick as winner" action available per column (reuses existing `openResolveDialog`).
|
|
||||||
- Toggle between grid view (current) and table view. Preserve both modes. Default to grid;
|
|
||||||
user activates comparison mode explicitly.
|
|
||||||
- Comparison mode is a UI state only (Zustand or local component state) — no URL change needed.
|
|
||||||
|
|
||||||
### Impact Preview
|
- `globalItems`: id, brand, model, name (display), categoryId, manufacturerWeightGrams, manufacturerPriceCents, productUrl, imageFilename, description, createdAt, updatedAt, createdByUserId
|
||||||
|
- User items get optional `globalItemId` FK for linking
|
||||||
|
- Admin-seeded initially; later users can suggest additions via a proposal workflow
|
||||||
|
|
||||||
- Setup selector: `<select>` or custom dropdown populated from `useSetups()`.
|
### Structured Review Schema
|
||||||
- On selection: load setup via `useSetup(id)`. Compute delta per candidate:
|
|
||||||
`candidate.weightGrams - matchingCategoryWeight` where `matchingCategoryWeight` is the
|
|
||||||
sum of setup item weights in the same category as the thread.
|
|
||||||
- Delta display: colored pill on each candidate column in comparison view:
|
|
||||||
- Negative delta (lighter) = green, prefixed with "−"
|
|
||||||
- Positive delta (heavier) = red, prefixed with "+"
|
|
||||||
- Zero = neutral gray
|
|
||||||
- Same pattern for cost delta.
|
|
||||||
- "No setup selected" state = no delta row shown.
|
|
||||||
- "Category not in setup" state = "not in setup" label instead of delta.
|
|
||||||
- No new API endpoints required. All data is client-side once setups are loaded.
|
|
||||||
|
|
||||||
### Drag-to-Rank
|
- `reviews`: id, userId, globalItemId, overallRating (1-5), createdAt, updatedAt
|
||||||
|
- `reviewDimensionRatings`: id, reviewId, dimensionId, rating (1-5)
|
||||||
|
- `reviewDimensions`: id, categoryId, name (e.g., "durability", "packability"), sortOrder
|
||||||
|
- Unique constraint: one review per user per global item
|
||||||
|
- Dimensions are per-category, admin-defined
|
||||||
|
|
||||||
- `@dnd-kit/sortable` with `SortableContext` wrapping the candidate list.
|
### Discovery Feed Approach
|
||||||
- `useSortable` hook per candidate with a drag handle (Lucide `grip-vertical` icon).
|
|
||||||
- Drag handle visible always (not hover-only) so the affordance is clear.
|
|
||||||
- On `onDragEnd`: recompute ranks using `arrayMove()`, call
|
|
||||||
`PATCH /api/threads/:threadId/candidates/rank` with `{ orderedIds: number[] }`.
|
|
||||||
- Server endpoint: bulk update `rank` for each candidate ID in the thread atomically.
|
|
||||||
- `rank` column: `INTEGER` nullable. Null = unranked (treated as lowest rank). Default to
|
|
||||||
`createdAt` order on first explicit rank save.
|
|
||||||
- Rank number badge: displayed on each CandidateCard corner (small gray circle, "1", "2", "3").
|
|
||||||
- Works in both grid view and comparison view.
|
|
||||||
|
|
||||||
### Pros/Cons Fields
|
Not a personalized algorithmic feed. Three content streams, each a simple sorted query:
|
||||||
|
|
||||||
- Two `textarea` inputs added to the existing `CandidateForm` (slide-out panel).
|
1. **Recent public setups** -- ORDER BY createdAt DESC, paginated
|
||||||
- Labels: "Pros" and "Cons" — plain text, no icons.
|
2. **Recently reviewed items** -- Global items with recent reviews, ORDER BY latest review date
|
||||||
- Displayed below the existing Notes field in the form.
|
3. **Popular gear** -- Global items ORDER BY linked owner count DESC
|
||||||
- Extend `updateCandidateSchema` with `pros: z.string().optional()` and `cons: z.string().optional()`.
|
|
||||||
- In comparison table: pros and cons rows display as plain text, line-wrapped.
|
|
||||||
- In card grid: pros/cons not shown on card surface (too much density). Visible only in edit
|
|
||||||
panel and comparison view.
|
|
||||||
|
|
||||||
### Schema Changes Required
|
No recommendation engine. No engagement scoring. Users browse with intent.
|
||||||
|
|
||||||
Two schema migrations needed for this milestone:
|
### User Profile Data
|
||||||
|
|
||||||
```sql
|
Minimal profile extending the auth provider's user record:
|
||||||
-- Migration 1: Candidate rank
|
|
||||||
ALTER TABLE thread_candidates ADD COLUMN rank INTEGER;
|
|
||||||
|
|
||||||
-- Migration 2: Candidate pros/cons
|
- Display name (from auth provider or custom)
|
||||||
ALTER TABLE thread_candidates ADD COLUMN pros TEXT;
|
- Avatar URL (from auth provider or uploaded)
|
||||||
ALTER TABLE thread_candidates ADD COLUMN cons TEXT;
|
- Bio (short text, 280 char limit)
|
||||||
```
|
- Joined date
|
||||||
|
- Public setups list (derived from setup visibility)
|
||||||
Both columns are nullable and backwards compatible. Existing candidates get `NULL` values.
|
- Review count (derived)
|
||||||
UI treats `NULL` rank as unranked, `NULL` pros/cons as empty string.
|
- Collection size (count of items, public stat)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Sources
|
## Sources
|
||||||
|
|
||||||
- [Designing The Perfect Feature Comparison Table — Smashing Magazine](https://www.smashingmagazine.com/2017/08/designing-perfect-feature-comparison-table/) — table layout patterns, sticky headers, progressive disclosure (HIGH confidence)
|
- [LighterPack](https://lighterpack.com/) -- Gear list builder, community standard for ultralight hikers. Public sharing via link, no profiles or reviews.
|
||||||
- [Comparison Tables for Products, Services, and Features — Nielsen Norman Group](https://www.nngroup.com/articles/comparison-tables/) — information architecture for comparison, anti-patterns (HIGH confidence)
|
- [LighterPack tutorial (99Boulders)](https://www.99boulders.com/lighterpack-tutorial) -- Feature overview including sharing, linking, limitations.
|
||||||
- [The Ultimate Drag-and-Drop Toolkit for React: @dnd-kit — BrightCoding (2025)](https://www.blog.brightcoding.dev/2025/08/21/the-ultimate-drag-and-drop-toolkit-for-react-a-deep-dive-into-dnd-kit/) — confirmed dnd-kit as current standard, react-beautiful-dnd abandoned (HIGH confidence)
|
- [GearGrams](https://www.geargrams.com/) -- Trip-based gear list tracker with weight classification.
|
||||||
- [dnd-kit Sortable Docs](https://docs.dndkit.com/presets/sortable) — SortableContext, useSortable, arrayMove patterns (HIGH confidence)
|
- [Trailspace](https://www.trailspace.com/) -- User gear reviews with structured Summary/Pros/Cons format and Review Corps program.
|
||||||
- [Ultralight: The Gear Tracking App I'm Leaving LighterPack For — TrailsMag](https://trailsmag.net/blogs/hiker-box/ultralight-the-gear-tracking-app-i-m-leaving-lighterpack-for) — LighterPack feature gap analysis (MEDIUM confidence)
|
- [Trailspace Review Form](https://www.trailspace.com/blog/2012/02/29/new-gear-review-form.html) -- Details on structured review fields with category-specific suggestions.
|
||||||
- [Comparing products: UX design best practices — Contentsquare](https://contentsquare.com/blog/comparing-products-design-practices-to-help-your-users-avoid-fragmented-comparison-7/) — fragmented comparison UX pitfalls (HIGH confidence)
|
- [MyGear](https://mygear.world/) -- Social app for sports gear with Locker, feed, AI gear recognition, challenges.
|
||||||
- [Drag and drop UI examples and UX tips — Eleken](https://www.eleken.co/blog-posts/drag-and-drop-ui) — drag affordance and visual feedback patterns (MEDIUM confidence)
|
- [Outdoor Gear Lab](https://www.outdoorgearlab.com/) -- Professional structured gear reviews with side-by-side comparison methodology.
|
||||||
- GearBox codebase analysis (src/db/schema.ts, src/server/services/, src/client/hooks/) — confirmed existing data model, no rank/pros/cons columns present (HIGH confidence)
|
- [Ultralight App](https://trailsmag.net/blogs/hiker-box/ultralight-the-gear-tracking-app-i-m-leaving-lighterpack-for) -- LighterPack alternative analysis showing community pain points.
|
||||||
|
- [Ready Set Sim](https://www.readysetsim.com/) -- Sim racing gear profiles and build sharing (cross-domain reference for hobby-agnostic patterns).
|
||||||
|
- [GetStream Social Feed Architecture](https://getstream.io/blog/social-media-feed/) -- Feed implementation patterns and anti-patterns.
|
||||||
|
|
||||||
---
|
---
|
||||||
*Feature research for: GearBox v1.3 — candidate comparison, setup impact preview, candidate ranking*
|
*Feature research for: GearBox v2.0 Platform Foundation -- multi-user gear discovery platform*
|
||||||
*Researched: 2026-03-16*
|
*Researched: 2026-04-03*
|
||||||
|
|||||||
@@ -1,368 +1,436 @@
|
|||||||
# Pitfalls Research
|
# Pitfalls Research
|
||||||
|
|
||||||
**Domain:** Adding side-by-side candidate comparison, setup impact preview, and drag-to-reorder ranking to an existing gear management app (GearBox v1.3)
|
**Domain:** Single-user to multi-user gear platform migration (GearBox v2.0)
|
||||||
**Researched:** 2026-03-16
|
**Researched:** 2026-04-03
|
||||||
**Confidence:** HIGH (derived from direct codebase analysis of v1.2 + verified with dnd-kit GitHub issues, TanStack Query docs, and Baymard comparison UX research)
|
**Confidence:** HIGH (based on direct codebase analysis of v1.4 + established migration patterns)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Critical Pitfalls
|
## Critical Pitfalls
|
||||||
|
|
||||||
### Pitfall 1: dnd-kit + React Query Cache Produces Visible Flicker on Drop
|
### Pitfall 1: Missing userId Filters Leak Data Between Users
|
||||||
|
|
||||||
**What goes wrong:**
|
**What goes wrong:**
|
||||||
When ranking candidates via drag-to-reorder, the naive approach is to call `mutate()` in `onDragEnd` and apply an optimistic update with `setQueryData` in the `onMutate` callback. Despite this, the item visibly snaps back to its original position for a split second before settling in the new position. The user sees a "jump" on every successful drop, which makes the ranking feature feel broken even though the data is correct.
|
Every query in the existing codebase operates without a `userId` filter. After adding `userId` columns to `items`, `categories`, `threads`, `setups`, and `settings`, any service function not updated to filter by `userId` will return or mutate other users' data. The current `getAllItems()` returns `db.select().from(items).innerJoin(...)` with zero WHERE clauses. One missed function means User A sees User B's gear.
|
||||||
|
|
||||||
|
The surface area is large: 6 service files, 19 MCP tools, 7 route files, aggregate queries in `totals`, the `duplicateItem` function, the `getCollectionSummary` MCP resource, setup-item joins, and thread resolution (which creates a new item).
|
||||||
|
|
||||||
**Why it happens:**
|
**Why it happens:**
|
||||||
dnd-kit's `SortableContext` derives its order from React state. When the order is stored in React Query's cache rather than local React state, there is a timing mismatch: dnd-kit reads the list order from the cache after the drop animation, but the cache update triggers a React re-render cycle that arrives one or two frames late. The drop animation briefly shows the item at its original position before the re-render reflects the new order. This is a known, documented issue in dnd-kit (GitHub Discussion #1522, Issue #921) that specifically affects React Query integrations.
|
Developers add `userId` to the schema, update the obvious CRUD functions, but miss edge cases. The codebase has enough query sites (~30+) that manual "find all queries" misses something. Thread resolution is particularly dangerous because it creates an item as a side effect of updating a thread.
|
||||||
|
|
||||||
**How to avoid:**
|
**How to avoid:**
|
||||||
Use a `tempItems` local state (`useState<Candidate[] | null>(null)`) alongside React Query. On `onDragEnd`, immediately set `tempItems` to the reordered array before calling `mutate()`. Render the candidate list from `tempItems ?? queryData.candidates`. In mutation `onSettled`, set `tempItems` to `null` to hand back control to React Query. This approach:
|
1. Enable Postgres Row-Level Security (RLS) as a safety net -- even if the app filters by `userId`, RLS prevents cross-user access at the database level.
|
||||||
- Prevents the flicker because the component re-renders from synchronous local state immediately
|
2. Add `userId` as NOT NULL to the Drizzle schema first, then use TypeScript compiler errors to find every query that needs updating (insert calls will fail where `userId` is required but not provided).
|
||||||
- Avoids `useEffect` syncing (which adds extra renders and is error-prone)
|
3. Write one integration test per entity: create data as User A, query as User B, assert empty results.
|
||||||
- Stays consistent with the existing React Query + Zustand pattern in the codebase
|
4. Grep the codebase for every `.from(items)`, `.from(categories)`, `.from(threads)`, `.from(setups)`, `.from(settings)` and verify each has a `userId` filter.
|
||||||
- Handles drag cancellation cleanly (reset `tempItems` on `onDragCancel`)
|
|
||||||
|
|
||||||
Do not store the rank column data in the React Query `["threads", threadId]` cache key in a way that requires invalidation and refetch after reorder — this causes a round-trip delay that amplifies the flicker.
|
|
||||||
|
|
||||||
**Warning signs:**
|
**Warning signs:**
|
||||||
- Item visibly snaps back to original position for ~1 frame on drop
|
- Any service function that does not accept a `userId` parameter after migration.
|
||||||
- `onDragEnd` calls `mutate()` but uses no local state bridge
|
- Tests that pass without specifying which user is performing the action.
|
||||||
- `setQueryData` is the only state update on drag end
|
- MCP tools that work without user context.
|
||||||
|
|
||||||
**Phase to address:**
|
**Phase to address:**
|
||||||
Candidate ranking phase — the `tempItems` pattern must be designed before building the drag UI, not retrofitted after noticing the flicker.
|
Multi-user data model phase. This is the single most important thing to get right. Do not add public content or discovery features until every query is provably user-scoped.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Pitfall 2: Rank Storage Using Integer Offsets Requires Bulk Writes
|
### Pitfall 2: Category Name Uniqueness Breaks in Multi-User
|
||||||
|
|
||||||
**What goes wrong:**
|
**What goes wrong:**
|
||||||
The most obvious approach for storing candidate rank order is adding a `sortOrder INTEGER` column to `thread_candidates` and storing 1, 2, 3... When the user drags candidate #2 to position #1, the naive fix is to update all subsequent candidates' `sortOrder` values to maintain contiguous integers. With 5 candidates, this is 5 UPDATE statements per drop. With rapid dragging, this creates a burst of writes where each intermediate position during the drag fires updates. If the app ever has threads with 10+ candidates (not uncommon for a serious gear decision), this becomes visible latency on every drag.
|
The current schema has `name: text("name").notNull().unique()` on the `categories` table -- a global unique constraint. When User A creates a "Bikepacking" category, User B cannot. The migration must change this to a composite unique constraint on `(userId, name)`.
|
||||||
|
|
||||||
**Why it happens:**
|
**Why it happens:**
|
||||||
Integer rank storage feels natural and maps directly to how arrays work. The developer adds `ORDER BY sort_order ASC` to the candidate query and calls it done. The performance problem is only discovered when testing with a realistic number of candidates and a fast drag gesture.
|
Single-user apps use simple unique constraints. Developers add `userId` to the table but forget to update the unique constraint from `unique(name)` to `unique(userId, name)`. The migration runs fine on an empty database but fails the moment a second user creates a category with a common name.
|
||||||
|
|
||||||
**How to avoid:**
|
**How to avoid:**
|
||||||
Use a `sortOrder REAL` column (floating-point) with fractional indexing — when inserting between positions A and B, assign `(A + B) / 2`. This means a drag requires only a single UPDATE for the moved item. Only trigger a full renumber (resetting all candidates to integers 1000, 2000, 3000... or similar spaced values) when the float precision degrades (approximately after 50+ nested insertions, unlikely in practice for this app). Start values at 1000, 5000 increments to give ample room.
|
Audit every `.unique()` constraint in the schema during migration. `categories.name` must become a composite unique on `(userId, name)`. The `users.username` unique stays global (desired). No other tables currently have unique constraints, but new tables (reviews, products) should use composite uniqueness from the start.
|
||||||
|
|
||||||
For GearBox's use case (typically 2-8 candidates per thread), integer storage is workable, but the fractional approach is cleaner and avoids the bulk-write problem entirely. The added complexity is minimal: one line of math in the service layer.
|
|
||||||
|
|
||||||
Regardless of storage strategy, add an index: `CREATE INDEX ON thread_candidates (thread_id, sort_order)`.
|
|
||||||
|
|
||||||
**Warning signs:**
|
**Warning signs:**
|
||||||
- `sortOrder` column uses `integer()` type in Drizzle schema
|
- Database constraint errors when a second user creates categories.
|
||||||
- Reorder service function issues multiple UPDATE statements in a loop
|
- Tests that only ever use one user.
|
||||||
- No transaction wrapping the bulk update
|
|
||||||
- Each drag event (not just the final drop) triggers a service call
|
|
||||||
|
|
||||||
**Phase to address:**
|
**Phase to address:**
|
||||||
Schema and service design phase for candidate ranking — the storage strategy must be chosen before building the sort UI, as changing from integer to fractional later requires a migration.
|
Multi-user data model phase, during schema migration.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Pitfall 3: Impact Preview Reads Stale Candidate Data
|
### Pitfall 3: Drizzle Schema Rewrite Is a Replacement, Not a Migration
|
||||||
|
|
||||||
**What goes wrong:**
|
**What goes wrong:**
|
||||||
The impact preview shows "+450g / +$89" next to each candidate — what this candidate would add to the selected setup. The calculation is: `(candidate.weightGrams - null) + setup.totalWeight`. But the candidate card data comes from the `["threads", threadId]` query cache, while the setup totals come from a separate `["setups", setupId]` query cache. These caches can be out of sync: the user edits a candidate's weight in one tab, invalidating the threads cache, but if the setup was fetched earlier and has not been refetched, the "current setup weight" baseline in the delta is stale. The preview shows a delta calculated against the wrong baseline.
|
Drizzle ORM schemas are dialect-specific. The current schema imports from `drizzle-orm/sqlite-core` and uses `sqliteTable`, `integer().primaryKey({ autoIncrement: true })`, and `real()`. The Postgres schema must import from `drizzle-orm/pg-core` and use `pgTable`, `serial()` or `integer().generatedAlwaysAsIdentity()`, and `doublePrecision()`. This is not a migration Drizzle can auto-generate -- it requires a full schema rewrite and a fresh migration history.
|
||||||
|
|
||||||
The second failure mode: if `candidate.weightGrams` is `null` (not yet entered), displaying `+-- / +$89` is confusing. Users see "null delta" and assume the comparison is broken rather than understanding that the candidate has no weight data.
|
Specific differences that will cause bugs if missed:
|
||||||
|
- `integer("id").primaryKey({ autoIncrement: true })` becomes `serial("id").primaryKey()` or `integer("id").primaryKey().generatedAlwaysAsIdentity()`.
|
||||||
|
- `integer("created_at", { mode: "timestamp" })` -- SQLite stores timestamps as integers. Postgres has native `timestamp` type. Must decide: keep integer storage or switch to Postgres `timestamp()`.
|
||||||
|
- `real("weight_grams")` -- SQLite `REAL` is 8-byte float. Postgres `real` is 4-byte float (less precision). Use `doublePrecision()` for equivalent behavior.
|
||||||
|
- SQLite `text("status")` with string values works as pseudo-enum. Postgres has native `pgEnum` for type safety.
|
||||||
|
- The `Db` type alias (`typeof prodDb`) changes entirely -- every service file and MCP tool imports this type.
|
||||||
|
|
||||||
**Why it happens:**
|
**Why it happens:**
|
||||||
Impact preview feels like pure computation — "just subtract two numbers." The developer writes it as a derived value from two props and does not think about cache coherence. The null case is often overlooked because the developer tests with complete candidate data.
|
Developers assume Drizzle abstracts away database differences. It does not at the schema layer. The query builder is mostly compatible, but schema definition is dialect-specific by design.
|
||||||
|
|
||||||
**How to avoid:**
|
**How to avoid:**
|
||||||
1. Derive the delta from data already co-located in one cache entry where possible. The thread detail query (`["threads", threadId]`) returns all candidates; the setup query (`["setups", setupId]`) returns items with weight. Compute the delta in the component using both: `delta = candidate.weightGrams - replacedItemWeight` where `replacedItemWeight` is taken from the currently loaded setup data.
|
1. Write a new `schema.ts` from scratch using `pg-core`, not edit the existing one.
|
||||||
2. Use `useQuery` for setup data with the setup selector in the same component that renders the comparison, so both data sources are reactive.
|
2. Start a fresh Drizzle migration history for Postgres. SQLite migrations are irrelevant.
|
||||||
3. Handle null weight explicitly: show "-- (no weight data)" not "--g" for candidates without weights. Make the null state visually distinct from a zero delta.
|
3. Write a data migration script that reads from old SQLite and inserts into new Postgres.
|
||||||
4. Do NOT make a server-side `/api/threads/:id/impact?setupId=:sid` endpoint that computes delta server-side — this creates a third cache entry to invalidate and adds network latency to what should be a purely client-side calculation.
|
4. Update the `Db` type alias in all service files.
|
||||||
|
5. Use `doublePrecision()` not `real()` for weight values to maintain precision parity with SQLite.
|
||||||
|
|
||||||
**Warning signs:**
|
**Warning signs:**
|
||||||
- Impact delta shows stale values after editing a candidate's weight
|
- Weight values losing precision (245.5g becoming 245.49999...).
|
||||||
- Null weight candidates show a numerical delta (treating null as 0)
|
- Timestamps behaving differently (integer epoch vs. native timestamp).
|
||||||
- Delta calculation is in a server route rather than a client-side derived value
|
- drizzle-kit refusing to generate migrations against the wrong dialect.
|
||||||
- Setup data is fetched via a different hook than the one used for candidate data, with no shared staleness boundary
|
|
||||||
|
|
||||||
**Phase to address:**
|
**Phase to address:**
|
||||||
Impact preview phase — establish the data flow (client-side derived from two existing queries) before building the UI so the stale-cache problem cannot arise.
|
Database migration phase. Must complete before any other v2.0 feature.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Pitfall 4: Side-by-Side Comparison Breaks at Narrow Widths
|
### Pitfall 4: Test Infrastructure Collapses During Database Switch
|
||||||
|
|
||||||
**What goes wrong:**
|
**What goes wrong:**
|
||||||
The comparison view is built with a fixed two-or-three-column grid for the candidate cards. On a laptop at 1280px it looks great. On a narrower viewport or when the browser window is partially shrunk, the columns collapse to ~200px each, making the candidate name truncated, the weight/price badges unreadable, and the notes text invisible. The user cannot actually compare the candidates — the view that was supposed to help them decide becomes unusable.
|
The entire test infrastructure is built on SQLite. `createTestDb()` uses `bun:sqlite` with `Database(":memory:")` and `drizzle-orm/bun-sqlite`. E2E tests use a file-based SQLite (`e2e/test.db`). After switching to Postgres, every test needs a Postgres connection -- no more in-memory databases.
|
||||||
|
|
||||||
GearBox's existing design philosophy is mobile-responsive, but comparison tables are inherently wide. The tension is real: side-by-side requires horizontal space that mobile cannot provide.
|
The MCP server hard-codes `db as prodDb` which is an SQLite Drizzle instance. The Hono context variable type for `db` changes. Every route handler that does `c.get("db")` gets a different type.
|
||||||
|
|
||||||
**Why it happens:**
|
**Why it happens:**
|
||||||
Comparison views are usually mocked at full desktop width. Responsiveness is added as an afterthought, and the "fix" is often to stack columns vertically on mobile — which defeats the entire purpose of side-by-side comparison.
|
In-memory SQLite is the best testing story in the Bun ecosystem -- fast, isolated, no external services. Postgres testing requires either: (a) a running Postgres instance, (b) testcontainers with Docker, or (c) PGlite (lightweight Postgres in WebAssembly). Developers delay updating tests and end up with a broken test suite for weeks.
|
||||||
|
|
||||||
**How to avoid:**
|
**How to avoid:**
|
||||||
1. Build the comparison view as a horizontally scrollable container on mobile (`overflow-x: auto`). Do not collapse to vertical stack — comparing stacked items is cognitively equivalent to switching between detail pages.
|
1. Adopt PGlite (`@electric-sql/pglite`) for unit/integration tests. It provides in-memory Postgres without Docker. Drizzle supports PGlite via `drizzle-orm/pglite`.
|
||||||
2. Limit the number of simultaneously compared candidates to 3 (or at most 4). Comparing 8 candidates side-by-side is unusable regardless of screen size.
|
2. Update `createTestDb()` to use PGlite instead of bun:sqlite.
|
||||||
3. Use a minimum column width (e.g., `min-width: 200px`) so the container scrolls horizontally before the column content becomes illegible.
|
3. For E2E tests, use Docker Compose with a test Postgres instance, or PGlite if performance is acceptable.
|
||||||
4. Sticky first column for candidate names when scrolling horizontally, so the user always knows which column they are reading.
|
4. Update the Hono context variable type to the new Postgres Drizzle instance type.
|
||||||
5. Test at 768px viewport width before considering the feature done.
|
5. Migrate test infrastructure in the same phase as the schema, not after.
|
||||||
|
|
||||||
**Warning signs:**
|
**Warning signs:**
|
||||||
- Comparison grid uses percentage widths that collapse below 150px
|
- `bun test` fails across the board after schema change.
|
||||||
- No horizontal scroll on the comparison container
|
- "Type 'BunSQLiteDatabase' is not assignable to type 'PgDatabase'" errors everywhere.
|
||||||
- Mobile viewport shows columns stacked vertically
|
- E2E tests silently skipped or disabled "temporarily."
|
||||||
- Candidate name or weight badges are truncated without tooltip
|
|
||||||
|
|
||||||
**Phase to address:**
|
**Phase to address:**
|
||||||
Side-by-side comparison UI phase — responsive behavior must be designed in, not retrofitted. The minimum column width and scroll container decision shapes the entire component structure.
|
Database migration phase. Tests must migrate alongside the schema.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Pitfall 5: Pros/Cons Fields Stored as Free Text in Column, Not Structured
|
### Pitfall 5: Auth Provider Integration Breaks Existing Sessions, API Keys, and MCP
|
||||||
|
|
||||||
**What goes wrong:**
|
**What goes wrong:**
|
||||||
Pros and cons per candidate are stored as two free-text columns: `pros TEXT` and `cons TEXT` on `thread_candidates`. The user types a multi-line blob of text into each field. The comparison view renders them as raw text blocks next to each other. Two problems emerge:
|
The current auth stores users, sessions, and API keys in the local database. Switching to an external auth provider means: (1) user identity moves external, (2) session management changes (JWT or OAuth flow vs. cookie sessions), (3) existing API keys become orphaned because they reference the old user table, (4) the MCP server authenticates via API keys stored locally, (5) E2E tests authenticate via `POST /api/auth/login` with a seeded user, (6) the onboarding flow (`POST /api/auth/setup`) creates the first user.
|
||||||
- Formatting: the comparison view cannot render individual pro/con bullet points because the data is unstructured blobs
|
|
||||||
- Length: one candidate has a 500-word "pros" essay; another has two words. The comparison columns have wildly unequal heights, making the side-by-side comparison visually chaotic and hard to scan
|
|
||||||
|
|
||||||
The deeper problem: free text in a comparison context produces noise, not signal. Users write "it's really lightweight and packable and the color options are nice" when what the comparison view needs is scannable bullet points.
|
|
||||||
|
|
||||||
**Why it happens:**
|
**Why it happens:**
|
||||||
Adding two text columns to `thread_candidates` is the simplest possible implementation. The developer tests it with neat, short text and it looks fine. The UX failure is only visible when a real user writes the way real users write.
|
Auth migration is treated as "swap the login page" when it touches the entire authentication surface: user identity, session lifecycle, API key management, MCP authentication, E2E test setup, and onboarding.
|
||||||
|
|
||||||
**How to avoid:**
|
**How to avoid:**
|
||||||
1. Store pros/cons as newline-delimited strings, not markdown or JSON. The UI splits on newlines and renders each line as a bullet. Simple, no parsing, no migration complexity.
|
1. Keep API keys in the local database even after auth moves external. API keys are long-lived credentials managed by the application, not the auth provider.
|
||||||
2. In the form, use a `<textarea>` with a placeholder of "one item per line." Show a character count.
|
2. Map external provider user IDs to a local `users` table. The external provider handles authentication; the local table handles application-level data (userId foreign keys, API keys, preferences). Foreign keys reference local `users.id`, not the provider's UUID.
|
||||||
3. In the comparison view, render each newline-delimited entry as its own row, so columns stay scannable. Use a max of 5 bullet points per field; truncate with "show more" if longer.
|
3. Replace the onboarding flow: instead of "create admin account," it becomes "sign up via external provider, first user gets admin role."
|
||||||
4. Cap `pros` and `cons` field length at 500 characters in the Zod schema to prevent essay-length blobs.
|
4. Update E2E tests to either mock the auth provider or use API key authentication exclusively for E2E.
|
||||||
5. The comparison view should truncate to the first 3 bullets when in compact comparison mode, with expand option.
|
|
||||||
|
|
||||||
**Warning signs:**
|
**Warning signs:**
|
||||||
- `pros TEXT` and `cons TEXT` added to schema with no length constraint
|
- MCP server stops working after auth migration.
|
||||||
- Comparison view renders `{candidate.pros}` as a raw string in a `<p>` tag
|
- E2E tests that log in via `POST /api/auth/login` all fail.
|
||||||
- One candidate's pros column is 3x taller than another's, making row alignment impossible
|
- API keys created before migration stop working.
|
||||||
- Form shows a full-height textarea with no guidance on format
|
- No local `users` table -- everything delegated to external provider.
|
||||||
|
|
||||||
**Phase to address:**
|
**Phase to address:**
|
||||||
Both the ranking schema phase (when pros/cons columns are added) and the comparison UI phase (when the rendering decision is made). The newline-delimited format must be decided at schema design time.
|
Auth migration phase. Should be done early because user identity is the foundation.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Pitfall 6: Impact Preview Compares Against Wrong Setup Total When Item Would Be Replaced
|
### Pitfall 6: Global Item Database Creates a Data Model Fork
|
||||||
|
|
||||||
**What goes wrong:**
|
**What goes wrong:**
|
||||||
The impact preview shows the delta for each candidate as if the candidate would be *added* to the setup. But the real use case is: "I want to replace my current tent with one of these candidates — which one saves the most weight?" The user expects the delta to reflect `candidateWeight - currentItemWeight`, not just `+candidateWeight`.
|
The current `items` table represents user-owned gear. The v2.0 vision includes a "global item database" with manufacturer specs. These are fundamentally different entities: a user's item has quantity, personal notes, setup associations, and belongs to a user. A global item is a product definition with canonical specs, owned by nobody. Conflating them in one table (via `isGlobal` flag or `NULL userId`) creates an unmaintainable mess. Separating them creates a sync problem.
|
||||||
|
|
||||||
When the delta is calculated as a pure addition (no replacement), a 500g candidate looks like "+500g" even though the item it replaces weighs 800g, meaning it would actually save 300g. The user sees a positive delta and dismisses the candidate when they should pick it.
|
|
||||||
|
|
||||||
**Why it happens:**
|
**Why it happens:**
|
||||||
"Impact" is ambiguous. The developer defaults to "how much weight does this add?" because that calculation is simpler (no need to identify which existing item is being replaced). The replacement case requires the user to specify which item in the setup would be swapped out, which feels like additional UX complexity.
|
It seems efficient to add an `isGlobal` flag. But then queries need to handle both cases, user items need to link to global items for spec inheritance, and the API surface doubles with different permission models.
|
||||||
|
|
||||||
**How to avoid:**
|
**How to avoid:**
|
||||||
1. Support both modes: "add to setup" (+delta) and "replace item" (delta = candidate - replaced item). Make the mode selection explicit in the UI.
|
1. Create a separate `products` table for the global database. A product has: name, manufacturer, canonical weight, canonical price, product URL, image, category.
|
||||||
2. Default to "add" mode if no item in the setup shares the same category as the thread. Default to "replace" mode if an item with the same category exists — offer it as a pre-populated suggestion ("Replaces: Big Agnes Copper Spur 2? Change").
|
2. User `items` gets a nullable `productId` foreign key. When set, the item inherits specs from the product but can override them (user's measured weight vs. manufacturer spec).
|
||||||
3. The replacement item selector should be a dropdown filtered to setup items in the same category, defaulting to the most likely match.
|
3. User items without a `productId` are standalone (backward-compatible with all existing items).
|
||||||
4. If no setup is selected, show raw candidate weight rather than a delta — do not calculate a delta against zero.
|
4. Reviews, owner counts, and setup appearances link to `products`, not user `items`.
|
||||||
|
|
||||||
**Warning signs:**
|
**Warning signs:**
|
||||||
- Delta is always positive (never shows weight savings)
|
- `items` table query complexity increases beyond what is reasonable.
|
||||||
- No replacement item selector in the impact preview UI
|
- Ambiguity about whether an operation affects "my item" or "the global product."
|
||||||
- Thread category is not used to suggest a candidate's likely replacement item
|
- Permission model becomes unclear (who can edit a global product?).
|
||||||
- Delta is calculated as `candidate.weightGrams` with no baseline
|
|
||||||
|
|
||||||
**Phase to address:**
|
**Phase to address:**
|
||||||
Impact preview design phase — the "add vs replace" distinction must be designed before building the service layer, because "add" and "replace" produce fundamentally different calculations and different UI affordances.
|
Global item database phase. Must come after multi-user data model is stable.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Pitfall 7: Schema Change Adds Columns Without Updating Test Helper
|
### Pitfall 7: Image Storage Migration Breaks Existing URLs and the MCP Tool
|
||||||
|
|
||||||
**What goes wrong:**
|
**What goes wrong:**
|
||||||
v1.3 requires adding `sortOrder`, `pros`, and `cons` to `thread_candidates`. The developer updates `src/db/schema.ts`, runs `bun run db:push`, and builds the feature. Tests fail with cryptic "no such column" errors — or worse, tests pass silently because they do not exercise the new columns, while the real database has them.
|
Images are stored in `./uploads/` on the filesystem, served via `app.use("/uploads/*", serveStatic({ root: "./" }))`, and referenced by `imageFilename` in the database. Moving to object storage changes URLs from `/uploads/uuid.jpg` to `https://bucket.s3.region.amazonaws.com/uuid.jpg`. Every existing `imageFilename` reference becomes a broken image.
|
||||||
|
|
||||||
This pitfall already existed in v1.2 (documented in the previous PITFALLS.md) and the test helper (`tests/helpers/db.ts`) uses raw CREATE TABLE SQL that must be manually kept in sync.
|
Both `items` and `threadCandidates` have `imageFilename` and `imageSourceUrl` fields. The MCP tool `upload_image_from_url` saves to the local filesystem. The image route `POST /api/images` saves to `./uploads/`.
|
||||||
|
|
||||||
**Why it happens:**
|
**Why it happens:**
|
||||||
Under time pressure, the developer focuses on the feature and forgets the test helper update. The error only surfaces when the new service function is called in a test. The CLAUDE.md documents this requirement, but it is easy to miss in the flow of development.
|
The current design stores only the filename, not the full URL. The serving path is implicit (prepend `/uploads/`). When storage moves to S3, the "prepend `/uploads/`" pattern breaks.
|
||||||
|
|
||||||
**How to avoid:**
|
**How to avoid:**
|
||||||
For every schema change in v1.3, update `tests/helpers/db.ts` in the same commit:
|
1. Add a reverse proxy route: keep `/uploads/*` working but proxy to S3 instead of local filesystem. This maintains backward compatibility during transition.
|
||||||
- `thread_candidates`: add `sort_order REAL DEFAULT 0`, `pros TEXT`, `cons TEXT`
|
2. Or migrate `imageFilename` to store full URLs. Existing filenames get prefixed with the S3 URL during data migration.
|
||||||
- Run `bun test` immediately after schema + helper update, before writing any other code
|
3. Write a migration script that uploads all `./uploads/` files to S3 and updates database references.
|
||||||
|
4. Update `POST /api/images`, `POST /api/images/from-url`, and the MCP `upload_image_from_url` tool to write to S3.
|
||||||
Consider writing a schema-parity test: compare the columns returned by `PRAGMA table_info(thread_candidates)` against a known expected list, failing if they differ. This catches the test-helper-out-of-sync problem automatically.
|
5. Create an image storage abstraction layer so dev can use local filesystem and production uses S3.
|
||||||
|
|
||||||
**Warning signs:**
|
**Warning signs:**
|
||||||
- Tests failing with `SqliteError: no such column`
|
- Broken images after deployment.
|
||||||
- New service function works in the running app but throws in `bun test`
|
- Mixed URLs (some `/uploads/`, some `https://s3...`) in the database.
|
||||||
- `bun run db:push` was run but `bun test` was not run afterward
|
- MCP tool `upload_image_from_url` silently failing.
|
||||||
- `tests/helpers/db.ts` has fewer columns than `src/db/schema.ts`
|
|
||||||
|
|
||||||
**Phase to address:**
|
**Phase to address:**
|
||||||
Every schema-touching phase of v1.3. The candidate ranking schema phase (sortOrder, pros, cons) is the primary risk. Check test helper parity as an explicit completion criterion.
|
Infrastructure phase. Should be done before discovery/public profiles (which serve images to many users).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Pitfall 8: Comparison View Includes Resolved Candidates
|
### Pitfall 8: Thread Resolution Creates Items Without Proper User Scoping
|
||||||
|
|
||||||
**What goes wrong:**
|
**What goes wrong:**
|
||||||
After a thread is resolved (winner picked), the thread's candidates still exist in the database. The comparison view, if it loads candidates from the existing `getThreadWithCandidates` response without filtering, will display the resolved winner alongside all losers — including the now-irrelevant candidates. A user revisiting a resolved thread to check why they picked Option A sees all candidates re-listed in the comparison view with no indication of which was selected, creating confusion.
|
Thread resolution copies a candidate's data into a new item. In multi-user, the newly created item must inherit the thread owner's `userId`. If the resolution logic does not explicitly set `userId` on the new item, it either fails (NOT NULL constraint) or creates an orphaned item.
|
||||||
|
|
||||||
The secondary problem: if the app allows drag-to-reorder ranking on a resolved thread, a user could accidentally fire rank-update mutations on a thread that should be read-only.
|
This is a specific instance of Pitfall 1 but deserves its own callout because resolution is a multi-step transaction: update thread status, set `resolvedCandidateId`, create new item. Any step that forgets `userId` breaks the chain.
|
||||||
|
|
||||||
**Why it happens:**
|
**Why it happens:**
|
||||||
The comparison view and ranking components are built for active threads and tested only with active threads. Resolved thread behavior is not considered during design.
|
The resolution logic is tested as a unit but the test does not set a `userId` because none existed. After adding `userId`, the test still passes if using a default/NULL value. The bug only surfaces with a second user.
|
||||||
|
|
||||||
**How to avoid:**
|
**How to avoid:**
|
||||||
1. Check `thread.status === "resolved"` before rendering the comparison/ranking UI. For resolved threads, render a read-only summary: "You chose [winner name]" with the winning candidate highlighted and others shown as non-interactive.
|
1. Make `userId` NOT NULL on all entity tables from day one.
|
||||||
2. Disable drag-to-reorder on resolved threads entirely — don't render the drag handles.
|
2. Update `resolveThread` to accept and propagate `userId`.
|
||||||
3. In the impact preview, disable the "Impact on Setup" panel for resolved threads and instead show "Added to collection on [date]" for the winning candidate.
|
3. Write a test: resolve thread as User A, verify created item belongs to User A.
|
||||||
4. The API route for rank updates should reject requests for resolved threads (return 400 with "Thread is resolved").
|
|
||||||
|
|
||||||
**Warning signs:**
|
**Warning signs:**
|
||||||
- Comparison/ranking UI renders identically for active and resolved threads
|
- Items appearing in the wrong user's collection after resolution.
|
||||||
- Drag handles are visible on resolved thread candidates
|
- Thread resolution failing with constraint violations.
|
||||||
- No `thread.status` check in the comparison view component
|
|
||||||
- Resolved threads accept rank update mutations
|
|
||||||
|
|
||||||
**Phase to address:**
|
**Phase to address:**
|
||||||
Comparison UI phase and ranking phase — both must include a resolved-thread guard. This is a correctness issue, not just a UX issue, because drag mutations on resolved threads corrupt state.
|
Multi-user data model phase.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pitfall 9: Public Content Without Explicit Privacy Controls
|
||||||
|
|
||||||
|
**What goes wrong:**
|
||||||
|
The v2.0 plan includes "public user profiles with shared setups" and a "discovery feed." Without explicit visibility controls, the default state is ambiguous: are new setups public? Are all items in a public setup visible? Can someone discover gear a user has not chosen to share? Users expecting a private gear tracker are surprised when their collection appears in search results.
|
||||||
|
|
||||||
|
**Why it happens:**
|
||||||
|
The developer defaults to "everything public" because it is simpler to build discovery features. Privacy controls are added as an afterthought, requiring a retroactive audit of all existing data.
|
||||||
|
|
||||||
|
**How to avoid:**
|
||||||
|
1. Default to private. Every entity (setup, profile) is private unless explicitly published.
|
||||||
|
2. Add a `visibility` column (`private` | `public`) to setups. Items are visible publicly only through public setups.
|
||||||
|
3. User profiles are private by default. Public profile is opt-in.
|
||||||
|
4. Public API endpoints (discovery, search) only query entities with `visibility = 'public'`.
|
||||||
|
5. Build the visibility model in the data layer before building any discovery UI.
|
||||||
|
|
||||||
|
**Warning signs:**
|
||||||
|
- No `visibility` or `isPublic` column in the schema.
|
||||||
|
- Discovery queries that do not filter by visibility.
|
||||||
|
- User complaints about unexpected data exposure.
|
||||||
|
|
||||||
|
**Phase to address:**
|
||||||
|
Multi-user data model phase (add visibility columns) and discovery phase (enforce in queries).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pitfall 10: SQLite-Specific Patterns That Silently Break on Postgres
|
||||||
|
|
||||||
|
**What goes wrong:**
|
||||||
|
The codebase has SQLite-specific patterns that will not error but will behave differently on Postgres:
|
||||||
|
- `src/db/index.ts` runs `PRAGMA journal_mode = WAL` and `PRAGMA foreign_keys = ON` -- Postgres has no PRAGMAs. Foreign keys are always enforced. WAL is always on.
|
||||||
|
- `bun:sqlite` is used as the driver. Postgres needs `postgres` (postgres.js) or `pg` (node-postgres) as the driver.
|
||||||
|
- The existing Drizzle migrator import is `drizzle-orm/bun-sqlite/migrator`. Postgres uses `drizzle-orm/node-postgres/migrator` or `drizzle-orm/postgres-js/migrator`.
|
||||||
|
- SQLite allows inserting strings into integer columns silently. Postgres will error.
|
||||||
|
- SQLite `AUTOINCREMENT` guarantees IDs never reuse. Postgres `serial` reuses IDs after deletions if the sequence is not explicitly configured.
|
||||||
|
- The test helper's `Database(":memory:")` has no Postgres equivalent without PGlite.
|
||||||
|
|
||||||
|
**Why it happens:**
|
||||||
|
These patterns are invisible in a working SQLite app. They only surface during or after the switch, often as runtime errors in production.
|
||||||
|
|
||||||
|
**How to avoid:**
|
||||||
|
1. Remove all PRAGMA statements when switching to Postgres.
|
||||||
|
2. Replace `bun:sqlite` driver with `postgres` (postgres.js is recommended for Bun compatibility).
|
||||||
|
3. Update all migrator imports.
|
||||||
|
4. Run the full test suite against Postgres to catch type strictness differences.
|
||||||
|
5. Use `serial` or `identity` columns for auto-increment; accept that IDs may be reused after deletion (this should not matter for a web app).
|
||||||
|
|
||||||
|
**Warning signs:**
|
||||||
|
- "PRAGMA" in the Postgres codebase.
|
||||||
|
- `bun:sqlite` imports anywhere in production code after migration.
|
||||||
|
- Tests passing against SQLite but failing against Postgres.
|
||||||
|
|
||||||
|
**Phase to address:**
|
||||||
|
Database migration phase.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pitfall 11: Setup-Item Delete-All-Reinsert Pattern Causes Phantom Reads
|
||||||
|
|
||||||
|
**What goes wrong:**
|
||||||
|
The current setup item sync uses delete-all-then-re-insert: `DELETE FROM setup_items WHERE setupId = X`, then re-insert all items. In single-user SQLite this is fine. In multi-user Postgres with concurrent writes: (a) race conditions if two users modify setups simultaneously, (b) brief windows where a public setup appears empty to concurrent readers.
|
||||||
|
|
||||||
|
**Why it happens:**
|
||||||
|
The pattern was chosen for simplicity (noted in CLAUDE.md: "Simpler than diffing, atomic in transaction"). "Atomic in transaction" only holds if the transaction isolation level prevents phantom reads, which is not the default in Postgres (`READ COMMITTED`).
|
||||||
|
|
||||||
|
**How to avoid:**
|
||||||
|
1. Wrap in an explicit transaction with `SERIALIZABLE` or `REPEATABLE READ` isolation for the sync operation.
|
||||||
|
2. Or switch to diff-based approach for public setups: compare existing vs. new list, delete removed, insert added.
|
||||||
|
3. For private setups, the delete-reinsert pattern with a basic transaction is acceptable.
|
||||||
|
|
||||||
|
**Warning signs:**
|
||||||
|
- Public setups briefly appearing empty.
|
||||||
|
- Foreign key violations in concurrent scenarios.
|
||||||
|
|
||||||
|
**Phase to address:**
|
||||||
|
Multi-user data model phase, when updating the setup service.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pitfall 12: Existing Data Has No Owner After Multi-User Migration
|
||||||
|
|
||||||
|
**What goes wrong:**
|
||||||
|
The existing SQLite database has items, categories, threads, setups -- all without a `userId` column. When the schema adds `userId NOT NULL`, the existing data needs an owner. If the migration script does not assign existing data to the original user, the data is either lost (NOT NULL violation prevents migration) or orphaned.
|
||||||
|
|
||||||
|
**Why it happens:**
|
||||||
|
The developer writes the new schema with `userId NOT NULL`, runs `db:push`, and the migration fails because existing rows have no `userId`. The "fix" is to make `userId` nullable, which undermines the entire data isolation model.
|
||||||
|
|
||||||
|
**How to avoid:**
|
||||||
|
1. The data migration script must: (a) create the original user in the new system, (b) assign all existing data to that user's ID, (c) then apply the NOT NULL constraint.
|
||||||
|
2. Migration order: create tables with `userId` nullable, insert data with the owner's userId, then ALTER to NOT NULL.
|
||||||
|
3. Verify row counts match before and after migration.
|
||||||
|
|
||||||
|
**Warning signs:**
|
||||||
|
- `userId` column is nullable in the final schema "because of migration."
|
||||||
|
- Existing data missing after migration.
|
||||||
|
- Migration script that only handles schema, not data.
|
||||||
|
|
||||||
|
**Phase to address:**
|
||||||
|
Database migration phase, specifically the data migration step.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Technical Debt Patterns
|
## Technical Debt Patterns
|
||||||
|
|
||||||
Shortcuts that seem reasonable but create long-term problems.
|
|
||||||
|
|
||||||
| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable |
|
| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable |
|
||||||
|----------|-------------------|----------------|-----------------|
|
|----------|-------------------|----------------|-----------------|
|
||||||
| Integer `sortOrder` instead of fractional | Simple schema | Bulk UPDATE on every reorder; bulk write latency with 10+ candidates | Acceptable only if max candidates per thread is enforced at 5 or fewer |
|
| Keeping SQLite test infrastructure while developing Postgres features | Tests keep passing during migration | Two database dialects to maintain, false confidence from tests that do not match production | Never -- migrate tests alongside schema |
|
||||||
| Server-side delta calculation endpoint | Simpler client code | Third cache entry to invalidate; network round-trip on every setup selection change | Never — the calculation is two subtractions using data already in client cache |
|
| Storing both old `/uploads/` paths and new S3 URLs | Avoid data migration script | Every image-rendering component handles both URL formats forever | Only as a 1-2 week transition |
|
||||||
| Pros/cons as unstructured free-text blobs | Zero schema complexity | Comparison view cannot render bullets; columns misalign | Never for comparison display — use newline-delimited format from day one |
|
| Using `userId` as nullable during migration | Existing data does not need backfilling | Every query must handle NULL userId, privacy bugs when userId is missing | Only during the migration transaction itself, then enforce NOT NULL |
|
||||||
| Comparison grid with `overflow: hidden` on narrow viewports | Avoids horizontal scroll complexity | Comparison becomes unreadable on laptop with panels open; critical feature breaks | Never — horizontal scroll is the correct behavior for comparison tables |
|
| Skipping RLS and relying only on app-level userId filtering | Faster to implement | Single missed WHERE clause = data leak | Never for multi-user platforms |
|
||||||
| Rendering comparison for resolved threads without guard | Simpler component logic | Users can drag-reorder resolved threads, corrupting state | Never — the resolved-thread guard is a correctness requirement |
|
| Deferring visibility controls to "after discovery ships" | Ship discovery faster | Retroactive privacy audit, potential data exposure, user trust damage | Never |
|
||||||
| `DragOverlay` using same component as `useSortable` | Less component code | ID collision in dnd-kit causes undefined behavior during drag | Never — dnd-kit explicitly requires a separate presentational component for DragOverlay |
|
| Keeping the local `users` table password hash after external auth | Avoid migration complexity | Dead column confuses future developers, potential security liability | Never -- remove password hash column after auth migration |
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Integration Gotchas
|
## Integration Gotchas
|
||||||
|
|
||||||
Common mistakes when connecting new v1.3 features to existing v1.2 systems.
|
| Integration | Common Mistake | Correct Approach |
|
||||||
|
|-------------|----------------|------------------|
|
||||||
| Integration Point | Common Mistake | Correct Approach |
|
| External auth provider | Removing the local `users` table entirely | Keep a local `users` table with `externalId` (from auth provider) + local fields (preferences, API keys). Foreign keys reference local `users.id`, not the external provider's UUID. |
|
||||||
|-------------------|----------------|------------------|
|
| External auth provider | Storing user profile data in the auth provider and querying it at runtime | Store only identity in auth provider. Sync user profile to local `users` table on login. Application queries local table only. |
|
||||||
| Ranking + React Query | Using `setQueryData` alone for optimistic reorder, causing flicker | Maintain `tempItems` local state in the drag component; render from `tempItems ?? queryData.candidates`; clear on `onSettled` |
|
| External auth provider | Using auth provider's session tokens directly as API authentication | Auth provider handles login/logout. Application mints its own session after verifying the auth provider's token. This decouples session lifecycle from the provider. |
|
||||||
| Impact preview + weight unit | Computing delta in grams but displaying with `formatWeight` that expects the stored unit | Delta is always computed in grams (raw stored values); apply `formatWeight(delta, unit)` once at display time, same pattern as all other weight displays |
|
| S3-compatible object storage | Using the S3 SDK directly in route handlers | Create an image storage abstraction (interface with `upload`, `getUrl`, `delete`). Swap implementations (local filesystem for dev, S3 for production) via environment config. |
|
||||||
| Impact preview + null weights | Treating `null` weightGrams as 0 in delta calculation | Show "-- (no weight data)" explicitly; never pass null to arithmetic; guard with `candidate.weightGrams != null && setup.totalWeight != null` |
|
| Postgres driver | Assuming `bun:sqlite` patterns work with Postgres | Postgres uses `postgres` (postgres.js) or `pg`. Connection pooling, async queries, and error handling differ. SQLite is synchronous; Postgres is async. Service functions may need to become async. |
|
||||||
| Pros/cons + thread resolution | Pros/cons text copied to collection item on resolve | Do NOT copy pros/cons to the items table — these are planning notes, not collection metadata. `resolveThread` in `thread.service.ts` should remain unchanged |
|
| Postgres | Assuming SQLite PRAGMA behaviors exist | Postgres has no PRAGMAs. Foreign keys are always on. WAL is always on. Remove all PRAGMA code. |
|
||||||
| Rank order + existing `getThreadWithCandidates` | Adding `ORDER BY sort_order` to `getThreadWithCandidates` changes the order of an existing query used by other components | Add `sort_order` to the SELECT and ORDER BY in `getThreadWithCandidates`. Audit all consumers of this query to verify they are unaffected by ordering change (the candidate cards already render in whatever order the query returns) |
|
| Drizzle ORM Postgres driver | Using synchronous `.get()` and `.all()` query methods | SQLite Drizzle uses `.get()` (sync). Postgres Drizzle uses `.execute()` or `await` on queries. Every service function that calls `.get()` or `.all()` must be updated. |
|
||||||
| Comparison view + `isActive` prop | `CandidateCard.tsx` uses `isActive` to show/hide the "Winner" button. Comparison view must not show "Winner" button inline if comparison has its own resolve affordance | Pass `isActive={false}` to `CandidateCard` when rendering inside comparison view, or create a separate `CandidateComparisonCard` presentational component that omits action buttons |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Performance Traps
|
## Performance Traps
|
||||||
|
|
||||||
Patterns that work at small scale but fail as usage grows.
|
|
||||||
|
|
||||||
| Trap | Symptoms | Prevention | When It Breaks |
|
| Trap | Symptoms | Prevention | When It Breaks |
|
||||||
|------|----------|------------|----------------|
|
|------|----------|------------|----------------|
|
||||||
| Bulk integer rank updates on every drag | Visible latency after each drop; multiple UPDATE statements per drag; SQLite write lock held | Use fractional `sortOrder REAL` so only the moved item requires an UPDATE | 8+ candidates per thread with rapid dragging |
|
| N+1 queries in discovery feed | Feed page takes 2+ seconds | Use joins or batch queries for setups with items and categories | 50+ setups in feed, each with 10+ items |
|
||||||
| Comparison view fetching all candidates for all threads | Slow initial load; excessive memory for large thread lists | Comparison view uses the already-loaded `["threads", threadId]` query; never fetches candidates outside the active thread's query | 20+ threads with 5+ candidates each |
|
| Unindexed `userId` columns | All queries slow after adding userId filtering | Add indexes on `userId` for every table. Composite indexes for `(userId, categoryId)` on items. | 1000+ items across 50+ users |
|
||||||
| Sync rank updates on every `dragOver` event (not just `dragEnd`) | Thousands of UPDATE mutations during a single drag; server overwhelmed; UI lags | Persist rank only on `onDragEnd` (drop), never on `onDragOver` (in-flight hover) | Any usage — `onDragOver` fires on every cursor pixel moved |
|
| Full-table scans for aggregates | Dashboard slow for large collections | Current aggregates are computed via SQL on read. Add materialized views or cache for public setup totals. | 100+ items per user, or public setups viewed by 100+ visitors |
|
||||||
| `useQuery` for setups list inside impact preview component | N+1 query pattern: each candidate card fetches its own setup list | Lift setup list query to the thread detail page level; pass selected setup as prop or context | 3+ candidates in comparison view |
|
| Image serving from app server | Server CPU/bandwidth saturated | Serve images from S3/CDN. Current `serveStatic` for uploads hits the app server for every request. | 100+ concurrent users browsing image-heavy pages |
|
||||||
|
| Global product search without full-text index | Product search slow or inaccurate | Use Postgres full-text search (`tsvector`/`tsquery`) or `pg_trgm` trigram index. | 10,000+ products |
|
||||||
---
|
| Synchronous service functions on Postgres | Request timeouts, connection pool exhaustion | SQLite Drizzle is sync. Postgres Drizzle is async. Service functions that were sync must become async. | Any usage under load |
|
||||||
|
|
||||||
## Security Mistakes
|
## Security Mistakes
|
||||||
|
|
||||||
Domain-specific security issues beyond general web security.
|
|
||||||
|
|
||||||
| Mistake | Risk | Prevention |
|
| Mistake | Risk | Prevention |
|
||||||
|---------|------|------------|
|
|---------|------|------------|
|
||||||
| `sortOrder` accepts any float value | Malformed values like `NaN`, `Infinity`, or extremely large floats stored in `sort_order` column, corrupting order | Validate `sortOrder` as a finite number in Zod schema: `z.number().finite()`. Reject `NaN` and `Infinity` at API boundary |
|
| No RLS, relying only on app-level userId filtering | Single missed WHERE clause exposes all user data | Enable Postgres RLS on all user-owned tables. App filtering is primary; RLS is safety net. |
|
||||||
| Pros/cons fields with no length limit | Users or automated input can store multi-kilobyte text blobs, inflating the database and slowing candidate queries | Cap at 500 characters per field in Zod: `z.string().max(500).optional()` |
|
| Public setup exposes private item details | Users share a setup but private notes/pricing leak | Public setup views project only public fields (name, weight, category). Define a "public item projection" and enforce it. |
|
||||||
| Rank update endpoint accepts any candidateId | A crafted request can reorder candidates from a different thread by passing a candidateId that belongs to another thread | In the rank update service, verify `candidate.threadId === threadId` before applying the update — same pattern as existing `resolveThread` validation |
|
| API keys not scoped to users after auth migration | API key created by User A operates on User B's data | API keys must associate with a userId. After validation, the key's userId scopes all operations. |
|
||||||
|
| Auth provider misconfigured for open self-registration | Random users create accounts without approval | Configure auth provider for admin-approval or invite-only registration. Test explicitly. |
|
||||||
---
|
| Image upload accepts any file type | Stored XSS via SVG uploads, executable content | Validate MIME type on upload (JPEG, PNG, WebP only). Set `Content-Type` and `Content-Disposition` headers. Strip EXIF metadata. |
|
||||||
|
| External auth provider callback URL not validated | OAuth redirect attack | Whitelist exact callback URLs in auth provider config. Never use wildcard redirect URIs. |
|
||||||
|
|
||||||
## UX Pitfalls
|
## UX Pitfalls
|
||||||
|
|
||||||
Common user experience mistakes in this domain.
|
|
||||||
|
|
||||||
| Pitfall | User Impact | Better Approach |
|
| Pitfall | User Impact | Better Approach |
|
||||||
|---------|-------------|-----------------|
|
|---------|-------------|-----------------|
|
||||||
| Comparison table loses column headers on scroll | User scrolls down to see notes/pros/cons and forgets which column is which candidate | Sticky column headers with candidate name, image thumbnail, and weight. Use `position: sticky; top: 0` on the header row |
|
| Forcing existing single user to re-register via external auth | User loses access to their own data until they figure out new login | Migration path: on first visit after upgrade, guide user to create auth provider account and automatically link to existing data. |
|
||||||
| Delta shows raw gram values when user prefers oz | Impact preview shows "+450g" to a user who has set their unit to oz | Apply `formatWeight(delta, unit)` using the `useWeightUnit()` hook, same as all other weight displays in the app |
|
| Public profiles default to showing everything | Users surprised their gear list is public | Default profile to private. Public is opt-in with clear preview of what others see. |
|
||||||
| Drag-to-reorder with no visual rank indicator | After ranking, it is unclear that the order matters or that #1 is the "top pick" | Show rank numbers (1, 2, 3...) as badges on each candidate card when in ranking mode. Update numbers live during drag |
|
| Review system with only star ratings | Ratings without context are useless for gear decisions | Structured reviews with predefined fields (durability, weight accuracy, value) per category. "Weight is 15g heavier than listed" is actionable; a 4-star rating is not. |
|
||||||
| Pros/cons fields empty by default in comparison view | Comparison table shows empty cells next to populated ones, making the comparison feel sparse and incomplete | Show a subtle "Add pros/cons" prompt in empty cells when the thread is active. In read-only resolved view, hide the pros/cons section entirely if no candidate has data |
|
| Discovery feed dominated by one hobby | Users in other hobbies see irrelevant content | Category-based feed filtering. Show content relevant to user's categories. |
|
||||||
| Impact preview setup selector defaults to no setup | User arrives at comparison view and sees no impact numbers because no setup is pre-selected | Default the setup selector to the most recently viewed/modified setup. Persist the last-selected setup in `sessionStorage` or a URL param |
|
| No indication of data ownership when browsing others' setups | User tries to edit someone else's setup and gets error | Clear visual distinction between "my setup" and "someone else's setup." Read-only view with "copy to my setups" action. |
|
||||||
| Removing a candidate clears comparison selection | User has candidates A, B, C in comparison; deletes C; comparison resets entirely | Comparison state (which candidates are selected) should be stored in local component state keyed by candidate ID. On delete, simply remove that ID from the selection |
|
| Settings lost during migration | User's weight unit preference, onboarding state disappear | Migrate the `settings` table data alongside everything else. Map settings to the original user. |
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## "Looks Done But Isn't" Checklist
|
## "Looks Done But Isn't" Checklist
|
||||||
|
|
||||||
Things that appear complete but are missing critical pieces.
|
- [ ] **Multi-user data model:** Often missing userId on the `settings` table -- verify settings are user-scoped (weight unit preference, onboarding state).
|
||||||
|
- [ ] **Multi-user data model:** Often missing userId filter on `threadCandidates` queries that join through `threads` -- verify candidates are not directly queryable across users.
|
||||||
- [ ] **Drag-to-reorder:** Often missing drag handles — verify the drag affordance is visually distinct (grip icon), not just "drag anywhere on the card" which conflicts with the existing click-to-edit behavior
|
- [ ] **Multi-user data model:** Often missing userId on thread resolution -- verify `resolveThread` propagates userId to the newly created item.
|
||||||
- [ ] **Drag-to-reorder:** Often missing keyboard reorder fallback — verify candidates can be moved with arrow keys for accessibility (dnd-kit's `KeyboardSensor` must be added to `DndContext`)
|
- [ ] **Auth migration:** Often missing MCP server auth update -- verify MCP tools operate in context of the authenticated user, not as global admin.
|
||||||
- [ ] **Drag-to-reorder:** Often missing flicker fix — verify dropping a candidate does not briefly snap back to original position (requires `tempItems` local state, not just `setQueryData`)
|
- [ ] **Auth migration:** Often missing E2E test auth update -- verify E2E tests authenticate against new auth system or use API keys.
|
||||||
- [ ] **Drag-to-reorder:** Often missing resolved-thread guard — verify drag handles are hidden and mutations are blocked on resolved threads
|
- [ ] **Auth migration:** Often missing API key userId association -- verify API keys created after migration are scoped to the creating user.
|
||||||
- [ ] **Impact preview:** Often missing the null weight case — verify candidates with no weight show "-- (no weight data)" not "NaNg" or "+0g"
|
- [ ] **Database migration:** Often missing data migration script -- verify existing SQLite data is actually moved to Postgres, not just the schema.
|
||||||
- [ ] **Impact preview:** Often missing the replace-vs-add distinction — verify the user can specify which existing item would be replaced, not just see a pure addition delta
|
- [ ] **Database migration:** Often missing timestamp conversion -- verify SQLite integer timestamps are correctly handled in Postgres schema.
|
||||||
- [ ] **Impact preview:** Often missing unit conversion — verify the delta respects `useWeightUnit()` and `useCurrency()`, not hardcoded to grams/USD
|
- [ ] **Database migration:** Often missing weight precision check -- verify `real()` vs `doublePrecision()` does not lose decimal precision.
|
||||||
- [ ] **Side-by-side comparison:** Often missing horizontal scroll on narrow viewports — verify the view is usable at 768px without column collapsing
|
- [ ] **Database migration:** Often missing sync-to-async conversion -- verify all service functions are async after Postgres switch.
|
||||||
- [ ] **Side-by-side comparison:** Often missing sticky headers — verify candidate names remain visible when scrolling the comparison rows
|
- [ ] **Image migration:** Often missing MCP tool update -- verify `upload_image_from_url` writes to S3, not local filesystem.
|
||||||
- [ ] **Pros/cons fields:** Often missing length validation — verify Zod schema caps the field and the textarea shows a character counter
|
- [ ] **Image migration:** Often missing `imageSourceUrl` field -- verify source URL metadata is preserved during migration.
|
||||||
- [ ] **Pros/cons display:** Often missing newline-to-bullet rendering — verify newlines in the stored text render as bullet points in the comparison view, not as `\n` characters
|
- [ ] **Public content:** Often missing visibility filtering on aggregate endpoints -- verify `/api/totals` only counts requesting user's items.
|
||||||
- [ ] **Schema changes:** Often missing test helper update — verify `tests/helpers/db.ts` includes `sort_order`, `pros`, and `cons` columns after the schema migration
|
- [ ] **Reviews:** Often missing rate limiting -- verify a user cannot submit 100 reviews in a minute.
|
||||||
|
- [ ] **Discovery feed:** Often missing pagination -- verify feed does not load all public setups at once.
|
||||||
---
|
- [ ] **Global items:** Often missing product-vs-item distinction -- verify adding a product to global database does not add it to anyone's collection.
|
||||||
|
|
||||||
## Recovery Strategies
|
## Recovery Strategies
|
||||||
|
|
||||||
When pitfalls occur despite prevention, how to recover.
|
|
||||||
|
|
||||||
| Pitfall | Recovery Cost | Recovery Steps |
|
| Pitfall | Recovery Cost | Recovery Steps |
|
||||||
|---------|---------------|----------------|
|
|---------|---------------|----------------|
|
||||||
| Drag flicker due to no `tempItems` local state | LOW | Add `tempItems` state to the ranking component. Render from `tempItems ?? queryData.candidates`. No data migration needed. |
|
| Data leaked between users (missing userId filter) | HIGH | Audit all queries, add RLS immediately, notify affected users, review access logs. Reputation damage is the real cost. |
|
||||||
| Integer `sortOrder` causing bulk updates | MEDIUM | Add Drizzle migration to change `sort_order` column type from INTEGER to REAL. Update existing rows to spaced values (1000, 2000, 3000...). Update service layer to use fractional logic. |
|
| Broken images after storage migration | MEDIUM | Keep old uploads directory as fallback. Re-upload missing images. Update database references. |
|
||||||
| Delta treats null weight as 0 | LOW | Add null guards in the delta calculation component. No data changes needed. |
|
| Test suite broken for weeks during DB migration | MEDIUM | Pause feature work. Set up PGlite test infrastructure. Port tests one file at a time. |
|
||||||
| Pros/cons stored as unformatted blobs | LOW | No migration needed — the data is still correct. Update the rendering component to split on newlines. Add length validation to the Zod schema for new input. |
|
| Auth migration breaks MCP server | LOW | MCP server can fall back to API key auth (already implemented). Fix isolated to MCP auth middleware. |
|
||||||
| Comparison view visible on resolved threads | LOW | Add `if (thread.status === 'resolved') return <ResolvedView />` before rendering the comparison/ranking UI. Add 400 check in the rank update API route. |
|
| Category unique constraint failures | LOW | Drop old unique constraint, add composite unique. Single transaction. |
|
||||||
| Test helper out of sync with schema | LOW | Update CREATE TABLE statements in `tests/helpers/db.ts`. Run `bun test`. Fix any test that relied on the old column count. |
|
| Weight precision loss (SQLite real to Postgres real) | LOW | Alter column to `doublePrecision`. One-time verification script. |
|
||||||
| Rank update accepts cross-thread candidateId | LOW | Add `candidate.threadId !== threadId` guard in rank update service (same pattern as existing `resolveThread` guard). |
|
| Public data exposure before visibility controls | HIGH | Emergency: set all entities to private, deploy, then build visibility controls properly. Cannot undo exposure. |
|
||||||
|
| Existing data orphaned after migration | MEDIUM | Re-run data migration script with correct userId assignment. Verify row counts. |
|
||||||
---
|
| Service functions still sync after Postgres switch | MEDIUM | Systematic conversion of all service functions to async. Update all callers. TypeScript will catch most issues. |
|
||||||
|
|
||||||
## Pitfall-to-Phase Mapping
|
## Pitfall-to-Phase Mapping
|
||||||
|
|
||||||
How roadmap phases should address these pitfalls.
|
|
||||||
|
|
||||||
| Pitfall | Prevention Phase | Verification |
|
| Pitfall | Prevention Phase | Verification |
|
||||||
|---------|------------------|--------------|
|
|---------|------------------|--------------|
|
||||||
| dnd-kit + React Query flicker | Candidate ranking phase | Drop a candidate, verify no snap-back. Add automated test: mock drag end, verify list order reflects drop position immediately. |
|
| Missing userId filters (P1) | Multi-user data model | Integration tests: create as User A, query as User B, assert empty. RLS policies active. |
|
||||||
| Bulk integer rank writes | Schema design for ranking | `sortOrder` column is `REAL` type in Drizzle schema. Service layer issues exactly one UPDATE per reorder. Test: reorder 5 candidates, verify only 1 DB write. |
|
| Category uniqueness (P2) | Multi-user data model | Two users create identically-named categories without constraint violations. |
|
||||||
| Stale data in impact preview | Impact preview phase | Change a candidate's weight, verify delta updates immediately. Select a different setup, verify delta recalculates from new baseline. |
|
| Drizzle schema rewrite (P3) | Database migration | Schema compiles with pg-core. drizzle-kit generates valid Postgres migrations. Weight values maintain precision. |
|
||||||
| Comparison broken at narrow width | Comparison UI phase | Test at 768px viewport. Verify horizontal scroll is present and content is readable. No vertical stack of comparison columns. |
|
| Test infrastructure collapse (P4) | Database migration | `bun test` passes with PGlite. E2E tests pass against Postgres. No SQLite imports in test code. |
|
||||||
| Pros/cons as unstructured blobs | Ranking schema phase (when columns added) | Verify Zod schema caps at 500 chars. Verify comparison view renders newlines as bullets. Test: enter 3-line pros text, verify 3 bullets rendered. |
|
| Auth provider breaks sessions/keys (P5) | Auth migration | Existing API keys work. MCP server authenticates. E2E tests pass. First-time setup works via external provider. |
|
||||||
| Impact preview add vs replace | Impact preview design phase | Thread with same-category item in setup defaults to replace mode. Pure-add mode available as alternative. Test: replace mode shows negative delta when candidate is lighter. |
|
| Global item data model fork (P6) | Global item database | Separate `products` table exists. User items optionally reference a product. CRUD operations distinct. |
|
||||||
| Comparison/rank on resolved threads | Both comparison and ranking phases | Verify drag handles are absent on resolved threads. Verify rank update API returns 400 for resolved thread. |
|
| Image URL breakage (P7) | Infrastructure / Image storage | Existing images render. New uploads go to S3. MCP upload tool works. |
|
||||||
| Test helper schema drift | Every schema-touching phase of v1.3 | After schema change, run `bun test` immediately. Zero test failures from column-not-found errors. |
|
| Thread resolution userId (P8) | Multi-user data model | Resolving a thread creates an item owned by the thread's owner. Tested with multiple users. |
|
||||||
|
| Privacy/visibility (P9) | Multi-user data model + Discovery | Default is private. Public queries filter by visibility. No private data in discovery feed. |
|
||||||
---
|
| SQLite-specific patterns (P10) | Database migration | No PRAGMAs in codebase. No bun:sqlite imports. All queries async. |
|
||||||
|
| Setup sync race conditions (P11) | Multi-user data model | Concurrent setup modifications do not produce empty setups or constraint violations. |
|
||||||
|
| Existing data ownership (P12) | Database migration | All existing data assigned to original user. Row counts match. userId NOT NULL enforced. |
|
||||||
|
|
||||||
## Sources
|
## Sources
|
||||||
|
|
||||||
- [dnd-kit Discussion #1522: React Query + DnD flicker](https://github.com/clauderic/dnd-kit/discussions/1522) — `tempItems` solution for React Query cache flicker
|
- Direct codebase analysis of GearBox v1.4 (schema.ts, services, auth middleware, MCP server, test helpers, db/index.ts, E2E seed)
|
||||||
- [dnd-kit Issue #921: Sorting not working with React Query](https://github.com/clauderic/dnd-kit/issues/921) — Root cause of the state lifecycle mismatch
|
- [Drizzle ORM PostgreSQL documentation](https://orm.drizzle.team/docs/get-started/postgresql-new)
|
||||||
- [dnd-kit Sortable Docs: OptimisticSortingPlugin](https://dndkit.com/concepts/sortable) — New API for handling optimistic reorder
|
- [Drizzle ORM SQLite column types](https://orm.drizzle.team/docs/column-types/sqlite)
|
||||||
- [TanStack Query: Optimistic Updates guide](https://tanstack.com/query/v4/docs/react/guides/optimistic-updates) — `onMutate`/`onSettled` rollback patterns
|
- [Drizzle ORM migrations documentation](https://orm.drizzle.team/docs/migrations)
|
||||||
- [Fractional Indexing: Steveruiz.me](https://www.steveruiz.me/posts/reordering-fractional-indices) — Why fractional keys beat integer reorder for databases
|
- [SQLite to PostgreSQL migration pitfalls (Open WebUI discussion)](https://github.com/open-webui/open-webui/discussions/21609)
|
||||||
- [Fractional Indexing SQLite library](https://github.com/sqliteai/fractional-indexing) — Implementation reference for base62 lexicographic sort keys
|
- [How to migrate from SQLite to PostgreSQL (Render)](https://render.com/articles/how-to-migrate-from-sqlite-to-postgresql)
|
||||||
- [Baymard Institute: Comparison Tool Design](https://baymard.com/blog/user-friendly-comparison-tools) — Sticky headers, horizontal scroll, minimum column width for product comparison UX
|
- [Multi-tenant architecture guide (WorkOS)](https://workos.com/blog/developers-guide-saas-multi-tenant-architecture)
|
||||||
- [NN/G: Comparison Tables](https://www.nngroup.com/articles/comparison-tables/) — Avoid prose in comparison cells; use scannable structured values
|
- [Multi-tenant vs single-tenant SaaS (Clerk)](https://clerk.com/blog/multi-tenant-vs-single-tenant)
|
||||||
- [LogRocket: When comparison charts hurt UX](https://blog.logrocket.com/ux-design/feature-comparison-tips-when-not-to-use/) — Comparison table anti-patterns
|
- [Migrating file storage to Amazon S3 (DZone)](https://dzone.com/articles/migrating-file-storage-to-amazon-s3)
|
||||||
- Direct codebase analysis of GearBox v1.2 (schema.ts, thread.service.ts, setup.service.ts, CandidateCard.tsx, useSetups.ts, useCandidates.ts, tests/) — existing patterns, integration points, and established conventions
|
- [Drizzle ORM PostgreSQL best practices 2025 (GitHub Gist)](https://gist.github.com/productdevbook/7c9ce3bbeb96b3fabc3c7c2aa2abc717)
|
||||||
|
|
||||||
---
|
---
|
||||||
*Pitfalls research for: GearBox v1.3 — Research & Decision Tools (side-by-side comparison, impact preview, candidate ranking)*
|
*Pitfalls research for: GearBox v2.0 -- Single-user to multi-user platform migration*
|
||||||
*Researched: 2026-03-16*
|
*Researched: 2026-04-03*
|
||||||
|
|||||||
@@ -1,198 +1,260 @@
|
|||||||
# Stack Research -- v1.3 Research & Decision Tools
|
# Stack Research
|
||||||
|
|
||||||
**Project:** GearBox
|
**Domain:** Multi-user gear management platform (v2.0 platform additions)
|
||||||
**Researched:** 2026-03-16
|
**Researched:** 2026-04-03
|
||||||
**Scope:** Stack additions for side-by-side candidate comparison, setup impact preview, and drag-to-reorder candidate ranking with pros/cons
|
**Confidence:** MEDIUM-HIGH
|
||||||
**Confidence:** HIGH
|
|
||||||
|
|
||||||
## Key Finding: Zero New Dependencies
|
This document covers ONLY the new stack additions for v2.0. The existing stack (React 19, Hono, Drizzle ORM, TanStack Router/Query, Tailwind CSS v4, Lucide React, Recharts, framer-motion, Zustand, Zod, Bun) is validated and unchanged.
|
||||||
|
|
||||||
All three v1.3 features are achievable with the existing stack. The drag-to-reorder feature, which would normally require a dedicated DnD library, is covered by `framer-motion`'s built-in `Reorder` component — already installed at v12.37.0 with React 19 support confirmed.
|
## Recommended Stack
|
||||||
|
|
||||||
## Recommended Stack: Existing Technologies Only
|
### Authentication -- Logto (Self-Hosted)
|
||||||
|
|
||||||
### No New Dependencies Required
|
| Technology | Version | Purpose | Why Recommended |
|
||||||
|
|------------|---------|---------|-----------------|
|
||||||
|
| Logto OSS | v1.36+ | External OIDC/OAuth 2.1 auth provider | TypeScript-native, purpose-built for app auth (not enterprise IAM), requires Postgres (shared infra), beautiful pre-built sign-in UI, React SDK with hooks, lightweight JWT validation on backend. MIT-licensed core. |
|
||||||
|
| @logto/react | ^4.0.13 | React SDK for auth flows | LogtoProvider wraps app, provides useLogto() hook for sign-in/sign-out/token access. Handles OIDC redirect flow, token refresh, and user info. |
|
||||||
|
| jose | ^6.2.2 | JWT validation on Hono backend | Zero-dependency, Bun-compatible, used to verify Logto-issued access tokens via JWKS. Recommended by Logto docs over heavier alternatives. |
|
||||||
|
|
||||||
| Feature | Library Needed | Status |
|
**Why Logto over alternatives:**
|
||||||
|---------|---------------|--------|
|
|
||||||
| Side-by-side comparison view | None — pure layout/UI | Existing Tailwind CSS |
|
|
||||||
| Setup impact preview | None — SQL delta calculation | Existing Drizzle ORM + TanStack Query |
|
|
||||||
| Drag-to-reorder candidates | `Reorder` component | Already in `framer-motion@12.37.0` |
|
|
||||||
| Pros/cons text fields | None — schema + form | Existing Drizzle + Zod + React |
|
|
||||||
|
|
||||||
### How Each Feature Uses the Existing Stack
|
| Provider | Why Not |
|
||||||
|
|----------|---------|
|
||||||
|
| Authentik | Python-based, heavyweight (designed for enterprise proxy/SSO), overkill for app-level auth. No React SDK -- requires raw OIDC integration. Better for infra-level SSO (Portainer, Grafana). |
|
||||||
|
| Zitadel | Go-based, Kubernetes-first architecture, AGPL 3.0 license (copyleft since 2025). Stronger for multi-tenant B2B SaaS. Over-engineered for a single-product platform. |
|
||||||
|
| SuperTokens | Session-based by default (not OIDC), requires embedding their middleware into your backend. Tighter coupling than external provider model. |
|
||||||
|
| Keycloak | Java-based, heavy memory footprint (1-2GB RAM), complex admin UI. Industry standard but vastly over-scoped for this use case. |
|
||||||
|
|
||||||
#### 1. Side-by-Side Candidate Comparison
|
**Integration pattern:** Logto runs as a separate Docker container alongside Postgres. React app redirects to Logto's hosted sign-in page for auth flows. Hono backend validates JWT access tokens from the Authorization header using `jose` JWKS verification -- no Logto SDK needed on the backend, just standard OIDC token validation. User identity is the Logto `sub` claim (a stable string ID), stored as `userId` on all user-owned records.
|
||||||
|
|
||||||
**No schema changes. No new dependencies.**
|
**Backend middleware pattern (Hono):**
|
||||||
|
|
||||||
| Existing Tech | How It Is Used |
|
|
||||||
|---------------|----------------|
|
|
||||||
| Tailwind CSS v4 | Responsive comparison table layout. Horizontal scroll on mobile with `overflow-x-auto`. Fixed first column (row labels) using `sticky left-0`. |
|
|
||||||
| TanStack Query (`useThread`) | Thread detail already fetches all candidates in one query. Comparison view reads from the same cached data — no new API endpoint. |
|
|
||||||
| Lucide React | Comparison row icons (weight, price, status, link). Already in the curated icon set. |
|
|
||||||
| `formatWeight` / `formatPrice` | Existing formatters handle display with selected unit/currency. No changes needed. |
|
|
||||||
| `useWeightUnit` / `useCurrency` | Existing hooks provide formatting context. Comparison view uses them identically to `CandidateCard`. |
|
|
||||||
|
|
||||||
**Implementation approach:** Add a view toggle (grid vs. comparison table) to the thread detail page. The comparison view is a `<table>` or CSS grid with candidates as columns and attributes as rows. Data already lives in `useThread` response — no API changes.
|
|
||||||
|
|
||||||
#### 2. Setup Impact Preview
|
|
||||||
|
|
||||||
**No new dependencies. Requires one new API endpoint.**
|
|
||||||
|
|
||||||
| Existing Tech | How It Is Used |
|
|
||||||
|---------------|----------------|
|
|
||||||
| Drizzle ORM | New query: for a given setup, sum `weight_grams` and `price_cents` of its items. Then compute delta against each candidate's `weight_grams` and `price_cents`. Pure arithmetic in the service layer. |
|
|
||||||
| TanStack Query | New `useSetupImpact(threadId, setupId)` hook fetching `GET /api/threads/:threadId/impact?setupId=X`. Returns array of `{ candidateId, weightDelta, costDelta }`. |
|
|
||||||
| Hono + Zod validator | New route validates `setupId` query param. Delegates to service function. |
|
|
||||||
| `formatWeight` / `formatPrice` | Format deltas with `+` prefix for positive values (candidate adds weight/cost) and `-` for negative (candidate is lighter/cheaper than what's already in setup). |
|
|
||||||
| `useSetups` hook | Existing `useSetups()` provides the setup list for the picker dropdown. |
|
|
||||||
|
|
||||||
**Delta calculation logic (server-side service):**
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// For each candidate in thread:
|
import { createRemoteJWKSet, jwtVerify } from "jose";
|
||||||
// weightDelta = candidate.weightGrams - (matching item in setup).weightGrams
|
|
||||||
// If no matching item in setup (it would be added, not replaced): delta = candidate.weightGrams
|
|
||||||
|
|
||||||
// A "matching item" means: item in setup with same categoryId as the thread.
|
const jwks = createRemoteJWKSet(
|
||||||
// This is the intended semantic: "how does picking this candidate affect my setup?"
|
new URL("https://logto.example.com/oidc/jwks")
|
||||||
```
|
|
||||||
|
|
||||||
**Key decision:** Impact preview is read-only and derived. It does not mutate any data. It computes what *would* happen if the candidate were picked, without modifying the setup. The delta is displayed inline on each candidate card or in the comparison view.
|
|
||||||
|
|
||||||
#### 3. Drag-to-Reorder Candidate Ranking with Pros/Cons
|
|
||||||
|
|
||||||
**No new DnD library. Requires schema changes.**
|
|
||||||
|
|
||||||
| Existing Tech | How It Is Used |
|
|
||||||
|---------------|----------------|
|
|
||||||
| `framer-motion@12.37.0` `Reorder` | `Reorder.Group` wraps the candidate list. `Reorder.Item` wraps each candidate card. `onReorder` updates local order state. `onDragEnd` fires the persist mutation. |
|
|
||||||
| Drizzle ORM | Two new columns on `thread_candidates`: `sortOrder integer` (default 0, lower = higher rank) and `pros text` / `cons text` (nullable). |
|
|
||||||
| TanStack Query mutation | `usePatchCandidate` for pros/cons text updates. `useReorderCandidates` for bulk sort order update after drag-end. |
|
|
||||||
| Hono + Zod validator | `PATCH /api/threads/:threadId/candidates/reorder` accepts `{ candidates: Array<{ id, sortOrder }> }`. `PATCH /api/candidates/:id` accepts `{ pros?, cons? }`. |
|
|
||||||
| Zod | Extend `updateCandidateSchema` with `pros: z.string().nullable().optional()`, `cons: z.string().nullable().optional()`, `sortOrder: z.number().int().optional()`. |
|
|
||||||
|
|
||||||
**Framer Motion `Reorder` API pattern:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { Reorder } from "framer-motion";
|
|
||||||
|
|
||||||
// State holds candidates sorted by sortOrder
|
|
||||||
const [orderedCandidates, setOrderedCandidates] = useState(
|
|
||||||
[...candidates].sort((a, b) => a.sortOrder - b.sortOrder)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// onReorder fires continuously during drag — update local state only
|
const authMiddleware = createMiddleware(async (c, next) => {
|
||||||
// onDragEnd fires once on drop — persist to DB
|
const token = c.req.header("Authorization")?.replace("Bearer ", "");
|
||||||
<Reorder.Group
|
if (!token) return c.json({ error: "Unauthorized" }, 401);
|
||||||
axis="y"
|
|
||||||
values={orderedCandidates}
|
const { payload } = await jwtVerify(token, jwks, {
|
||||||
onReorder={setOrderedCandidates}
|
issuer: "https://logto.example.com/oidc",
|
||||||
>
|
audience: "your-api-resource-indicator",
|
||||||
{orderedCandidates.map((candidate) => (
|
});
|
||||||
<Reorder.Item
|
|
||||||
key={candidate.id}
|
c.set("userId", payload.sub);
|
||||||
value={candidate}
|
await next();
|
||||||
onDragEnd={() => persistOrder(orderedCandidates)}
|
});
|
||||||
>
|
|
||||||
<CandidateCard ... />
|
|
||||||
</Reorder.Item>
|
|
||||||
))}
|
|
||||||
</Reorder.Group>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Schema changes required:**
|
**React provider pattern:**
|
||||||
|
|
||||||
| Table | Column | Type | Default | Purpose |
|
```typescript
|
||||||
|-------|--------|------|---------|---------|
|
import { LogtoProvider, LogtoConfig } from "@logto/react";
|
||||||
| `thread_candidates` | `sort_order` | `integer NOT NULL` | `0` | Rank position (lower = higher rank) |
|
|
||||||
| `thread_candidates` | `pros` | `text` | `NULL` | Free-text pros annotation |
|
|
||||||
| `thread_candidates` | `cons` | `text` | `NULL` | Free-text cons annotation |
|
|
||||||
|
|
||||||
**Sort order persistence pattern:** On drag-end, send the full reordered array with new `sortOrder` values (0-based index positions). Backend replaces existing `sort_order` values atomically. This is the same delete-all + re-insert pattern used for `setupItems` but as an UPDATE instead.
|
const config: LogtoConfig = {
|
||||||
|
endpoint: "https://logto.example.com",
|
||||||
|
appId: "<your-app-id>",
|
||||||
|
resources: ["https://api.gearbox.example.com"],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wrap app root
|
||||||
|
<LogtoProvider config={config}>
|
||||||
|
<App />
|
||||||
|
</LogtoProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database -- PostgreSQL via Bun Native Driver
|
||||||
|
|
||||||
|
| Technology | Version | Purpose | Why Recommended |
|
||||||
|
|------------|---------|---------|-----------------|
|
||||||
|
| PostgreSQL | 16+ | Primary database | Required by Logto anyway, proper concurrent access for multi-user, JSONB for flexible spec fields, full-text search for discovery feed. |
|
||||||
|
| drizzle-orm | ^0.45.1 (existing) | Type-safe ORM | Already in use. Switch from `drizzle-orm/bun-sqlite` to `drizzle-orm/bun-sql` for Postgres. Schema definitions move from `sqlite-core` to `pg-core`. |
|
||||||
|
| Bun native SQL | built-in | Postgres driver | Zero additional dependencies. `import { SQL } from "bun"` provides native Postgres bindings. Drizzle ORM supports it via `drizzle-orm/bun-sql`. |
|
||||||
|
| postgres (postgres.js) | ^3.4.8 | Fallback Postgres driver | Only needed if Bun native SQL has issues with drizzle-kit CLI tooling (known issue #4122). More mature ecosystem, proven with Drizzle. Install as dev dependency for drizzle-kit. |
|
||||||
|
|
||||||
|
**Schema migration approach:**
|
||||||
|
|
||||||
|
1. Rewrite `src/db/schema.ts` imports from `drizzle-orm/sqlite-core` to `drizzle-orm/pg-core`
|
||||||
|
2. Replace `sqliteTable` with `pgTable`
|
||||||
|
3. Replace `integer().primaryKey({ autoIncrement: true })` with `integer().primaryKey().generatedAlwaysAsIdentity()` for PKs
|
||||||
|
4. Replace `integer("created_at", { mode: "timestamp" })` with `timestamp("created_at").defaultNow().notNull()`
|
||||||
|
5. Add `userId text("user_id").notNull()` to all user-owned tables (items, threads, setups, categories)
|
||||||
|
6. Add `visibility text("visibility").notNull().default("private")` to setups and profiles
|
||||||
|
7. Generate fresh Postgres migration with `drizzle-kit generate`
|
||||||
|
8. Write a one-time data migration script (SQLite read -> Postgres insert) for existing data
|
||||||
|
|
||||||
|
**drizzle.config.ts change:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before
|
||||||
|
{ dialect: "sqlite", dbCredentials: { url: "./gearbox.db" } }
|
||||||
|
|
||||||
|
// After
|
||||||
|
{ dialect: "postgresql", dbCredentials: { url: process.env.DATABASE_URL } }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Known issue:** drizzle-kit CLI does not use the Bun SQL driver for `push`/`generate` commands (GitHub issue #4122). Workaround: install `postgres` (postgres.js) as a dev dependency for drizzle-kit, while the app runtime uses Bun native SQL.
|
||||||
|
|
||||||
|
### Image Storage -- Bun Native S3 + MinIO
|
||||||
|
|
||||||
|
| Technology | Version | Purpose | Why Recommended |
|
||||||
|
|------------|---------|---------|-----------------|
|
||||||
|
| Bun S3Client | built-in | S3 API client | Zero dependencies, native Bun bindings, extends Blob interface. Supports presigned URLs, streaming uploads. Built-in MinIO compatibility. |
|
||||||
|
| MinIO | latest | Self-hosted S3-compatible object storage | Replaces local `./uploads/` directory. Single Go binary, Docker-friendly, S3 API compatible. Handles multi-user image scaling without cloud vendor lock-in. |
|
||||||
|
|
||||||
|
**Why Bun native S3 over @aws-sdk/client-s3:**
|
||||||
|
|
||||||
|
- Zero additional dependencies (Bun ships with it)
|
||||||
|
- Simpler API (extends Blob, web-standard patterns)
|
||||||
|
- Native performance bindings
|
||||||
|
- Full MinIO compatibility documented by Bun team
|
||||||
|
|
||||||
|
**Migration from ./uploads/:**
|
||||||
|
|
||||||
|
1. Deploy MinIO container alongside app
|
||||||
|
2. Create `gearbox-images` bucket
|
||||||
|
3. Write migration script to upload existing files from `./uploads/` to MinIO
|
||||||
|
4. Update image service to use S3Client for reads/writes
|
||||||
|
5. Serve images via presigned URLs or a proxy route on Hono
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { S3Client } from "bun";
|
||||||
|
|
||||||
|
const storage = new S3Client({
|
||||||
|
accessKeyId: process.env.S3_ACCESS_KEY!,
|
||||||
|
secretAccessKey: process.env.S3_SECRET_KEY!,
|
||||||
|
bucket: "gearbox-images",
|
||||||
|
endpoint: process.env.S3_ENDPOINT!, // e.g., http://minio:9000
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Supporting Libraries
|
||||||
|
|
||||||
|
| Library | Version | Purpose | When to Use |
|
||||||
|
|---------|---------|---------|-------------|
|
||||||
|
| jose | ^6.2.2 | JWKS-based JWT verification | Every authenticated API request -- validate Logto access tokens on Hono middleware |
|
||||||
|
| @logto/react | ^4.0.13 | React auth provider + hooks | Wrap app root, sign-in/sign-out flows, access token retrieval for API calls |
|
||||||
|
|
||||||
|
### Development / Infrastructure
|
||||||
|
|
||||||
|
| Tool | Purpose | Notes |
|
||||||
|
|------|---------|-------|
|
||||||
|
| Docker Compose | Local dev environment | Postgres + Logto + MinIO containers. App still runs on bare Bun for HMR. |
|
||||||
|
| drizzle-kit | Schema management | Same tool, different dialect config. `bun run db:generate` and `bun run db:push` still work. |
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# No new packages. Zero.
|
# New production dependencies
|
||||||
```
|
bun add @logto/react jose
|
||||||
|
|
||||||
All required capabilities are already installed.
|
# New dev dependencies (for drizzle-kit Postgres support)
|
||||||
|
bun add -D postgres
|
||||||
|
|
||||||
|
# No install needed for:
|
||||||
|
# - Bun native S3 (built-in)
|
||||||
|
# - Bun native SQL/Postgres (built-in)
|
||||||
|
# - drizzle-orm (already installed, just change imports)
|
||||||
|
```
|
||||||
|
|
||||||
## Alternatives Considered
|
## Alternatives Considered
|
||||||
|
|
||||||
### Drag and Drop: Why Not Add a Dedicated Library?
|
### Authentication Provider
|
||||||
|
|
||||||
| Option | Version | React 19 Status | Verdict |
|
| Recommended | Alternative | When to Use Alternative |
|
||||||
|--------|---------|-----------------|---------|
|
|-------------|-------------|-------------------------|
|
||||||
| `framer-motion` Reorder (already installed) | 12.37.0 | React 19 explicit peerDep (`^18.0.0 || ^19.0.0`) | USE THIS |
|
| Logto | Authentik | If you need proxy-mode SSO for non-OIDC apps (Portainer, legacy tools) |
|
||||||
| `@dnd-kit/core` + `@dnd-kit/sortable` | 6.3.1 | No React 19 support (stale ~1yr, open GitHub issue #1511) | AVOID |
|
| Logto | Zitadel | If building multi-tenant B2B SaaS with organization-level isolation |
|
||||||
| `@dnd-kit/react` (new rewrite) | 0.3.2 | React 19 compatible | Pre-1.0, no maintainer ETA on stable |
|
| Logto | Keycloak | If enterprise LDAP/AD integration is mandatory |
|
||||||
| `@hello-pangea/dnd` | 18.0.1 | No React 19 (stale ~1yr, peerDep `^18.0.0` only) | AVOID |
|
|
||||||
| `pragmatic-drag-and-drop` | latest | Core is React-agnostic but some sub-packages missing React 19 | Overkill for a single sortable list |
|
|
||||||
| Custom HTML5 DnD | N/A | N/A | 200+ lines of boilerplate, worse accessibility |
|
|
||||||
|
|
||||||
**Why framer-motion `Reorder` wins:** Already in the bundle. React 19 peer dep confirmed in lockfile. Handles the single use case (vertical sortable list) with 10 lines of code. Provides smooth layout animations at zero additional cost. The limitation (no cross-container drag, no multi-row grid) does not apply — candidate ranking is a single vertical list.
|
### Database Driver
|
||||||
|
|
||||||
**Why not `@dnd-kit`:** The legacy `@dnd-kit/core@6.3.1` has no official React 19 support and has been unmaintained for ~1 year. The new `@dnd-kit/react@0.3.2` does support React 19 but is pre-1.0 with zero maintainer response on stability/roadmap questions (GitHub Discussion #1842 has 0 replies). Adding a pre-1.0 library when the project already has a working solution is unjustifiable.
|
| Recommended | Alternative | When to Use Alternative |
|
||||||
|
|-------------|-------------|-------------------------|
|
||||||
|
| Bun native SQL (`bun:sql`) | postgres.js | If Bun native SQL has concurrency bugs (known issue in Bun 1.2.0 with concurrent statements) |
|
||||||
|
| Bun native SQL (`bun:sql`) | @neondatabase/serverless | If deploying to serverless/edge where persistent connections are not possible |
|
||||||
|
|
||||||
### Setup Impact: Why Not Client-Side Calculation?
|
### Image Storage
|
||||||
|
|
||||||
Client-side delta calculation (using cached React Query data) is simpler to implement but:
|
| Recommended | Alternative | When to Use Alternative |
|
||||||
- Requires loading both the full setup items list AND all candidates into the client
|
|-------------|-------------|-------------------------|
|
||||||
- Introduces staleness bugs if setup items change in another tab
|
| MinIO (self-hosted) | Cloudflare R2 | If you want zero-ops storage with no egress fees and don't mind cloud dependency |
|
||||||
- Is harder to test (service test vs. component test)
|
| MinIO (self-hosted) | Local filesystem (current) | For development/testing only. Not viable for multi-user at scale. |
|
||||||
|
|
||||||
Server-side calculation in a service function is testable, authoritative, and consistent with the existing architecture (services compute aggregates, not components).
|
|
||||||
|
|
||||||
## What NOT to Add
|
## What NOT to Add
|
||||||
|
|
||||||
| Avoid | Why | Use Instead |
|
| Avoid | Why | Use Instead |
|
||||||
|-------|-----|-------------|
|
|-------|-----|-------------|
|
||||||
| `@dnd-kit/core` + `@dnd-kit/sortable` | No React 19 support, stale for ~1 year (latest 6.3.1 from 2024) | `framer-motion` Reorder (already installed) |
|
| @aws-sdk/client-s3 | 60+ transitive dependencies, Bun has native S3 support | Bun built-in S3Client |
|
||||||
| `@hello-pangea/dnd` | No React 19 support, peerDep `react: "^18.0.0"` only, stale | `framer-motion` Reorder |
|
| passport.js / express-session | Wrong paradigm -- we want external OIDC, not embedded session auth | Logto + jose JWT validation |
|
||||||
| `react-comparison-table` or similar component packages | Fragile third-party layouts for a simple table. Custom Tailwind table is trivial and design-consistent. | Custom Tailwind CSS table layout |
|
| next-auth / auth.js | Designed for Next.js, assumes framework integration we don't have | Logto (external provider) |
|
||||||
| Modal/dialog library (Radix, Headless UI) | The project already has a hand-rolled modal pattern (`SlideOutPanel`, `ConfirmDialog`). Adding a library for one more dialog adds inconsistency. | Extend existing modal patterns |
|
| better-auth | Embedded auth library, opposite of external provider model | Logto (external provider) |
|
||||||
| Rich text editor for pros/cons | Markdown editors are overkill for a single-line annotation field. Users want a quick note, not a document. | Plain `<textarea>` with Tailwind styling |
|
| pg (node-postgres) | Callback-based API, Bun has native Postgres bindings | Bun native SQL or postgres.js |
|
||||||
|
| sharp / image processing libs | Premature optimization -- serve originals first, add resizing later if needed | Direct S3 storage of originals |
|
||||||
|
| Redis | Not needed at this scale. Postgres handles sessions (via Logto), caching is premature | Postgres for everything |
|
||||||
|
| Prisma | Already using Drizzle ORM, no reason to add a second ORM | drizzle-orm (existing) |
|
||||||
|
| nanoid / cuid2 | Postgres `gen_random_uuid()` is built-in for public-facing IDs if needed | Postgres native UUID generation |
|
||||||
|
| TypeORM / Sequelize | Legacy ORMs with worse TypeScript support than Drizzle | drizzle-orm (existing) |
|
||||||
|
|
||||||
## Stack Patterns by Variant
|
## Infrastructure Architecture
|
||||||
|
|
||||||
**If the comparison view needs mobile scroll:**
|
```
|
||||||
- Wrap comparison table in `overflow-x-auto`
|
Docker Compose (dev) / Docker (prod)
|
||||||
- Freeze the first column (attribute labels) with `sticky left-0 bg-white z-10`
|
+-- gearbox-app (Bun, port 3000)
|
||||||
- This is pure CSS, no JavaScript or library needed
|
+-- gearbox-postgres (PostgreSQL 16, port 5432)
|
||||||
|
| +-- gearbox DB (app data)
|
||||||
|
| +-- logto DB (Logto data, separate database same instance)
|
||||||
|
+-- gearbox-logto (Logto OSS, port 3001 app / 3002 admin)
|
||||||
|
+-- gearbox-minio (MinIO, port 9000 API / 9001 console)
|
||||||
|
```
|
||||||
|
|
||||||
**If the setup impact preview needs a setup picker:**
|
Logto and the app share a single Postgres instance (different databases). This keeps infrastructure simple -- one Postgres to back up, one to monitor. Logto requires PostgreSQL 14+; using 16 covers both.
|
||||||
- Use `useSetups()` (already exists) to populate a `<select>` dropdown
|
|
||||||
- Store selected setup ID in local component state (not URL params — this is transient UI)
|
|
||||||
- No new state management needed
|
|
||||||
|
|
||||||
**If pros/cons fields need to auto-save:**
|
|
||||||
- Use a debounced mutation (300-500ms) that fires on `onChange`
|
|
||||||
- Or save on `onBlur` (simpler, adequate for this use case)
|
|
||||||
- Existing `useUpdateCandidate` hook already handles candidate mutations — extend schema only
|
|
||||||
|
|
||||||
## Version Compatibility
|
## Version Compatibility
|
||||||
|
|
||||||
| Package | Version in Project | React 19 Compatible | Notes |
|
| Package | Compatible With | Notes |
|
||||||
|---------|-------------------|---------------------|-------|
|
|---------|-----------------|-------|
|
||||||
| `framer-motion` | 12.37.0 | YES — peerDeps `"^18.0.0 || ^19.0.0"` confirmed in lockfile | `Reorder` component available since v5 |
|
| drizzle-orm@0.45.x | Bun native SQL | Supported via `drizzle-orm/bun-sql` driver |
|
||||||
| `drizzle-orm` | 0.45.1 | N/A (server-side) | ALTER TABLE or migration for new columns |
|
| drizzle-orm@0.45.x | postgres.js@3.4.x | Supported via `drizzle-orm/postgres-js` driver (fallback) |
|
||||||
| `zod` | 4.3.6 | N/A | Extend existing schemas |
|
| drizzle-kit@0.31.x | PostgreSQL 16 | Generates Postgres-dialect migrations |
|
||||||
| `@tanstack/react-query` | 5.90.21 | YES | New hooks follow existing patterns |
|
| @logto/react@4.x | React 19 | Uses React context/hooks, compatible |
|
||||||
|
| jose@6.x | Bun runtime | Explicitly lists Bun support in docs |
|
||||||
|
| Logto OSS v1.36 | PostgreSQL 14+ | Logto requires PG 14 minimum; use PG 16 for both app and Logto |
|
||||||
|
| Bun S3Client | MinIO latest | Documented compatibility with endpoint configuration |
|
||||||
|
|
||||||
|
## Migration Checklist (SQLite to Postgres)
|
||||||
|
|
||||||
|
1. **Schema rewrite**: `sqlite-core` -> `pg-core` imports, adjust column types
|
||||||
|
2. **Driver swap**: `drizzle-orm/bun-sqlite` -> `drizzle-orm/bun-sql`
|
||||||
|
3. **Config update**: `drizzle.config.ts` dialect and credentials
|
||||||
|
4. **Fresh migrations**: Generate from scratch for Postgres (do not try to convert SQLite migrations)
|
||||||
|
5. **Data migration**: One-time script reads SQLite, writes to Postgres
|
||||||
|
6. **Test infrastructure**: Update `createTestDb()` helper to use Postgres test database (or pg-mem for in-memory testing)
|
||||||
|
7. **CI pipeline**: Add Postgres service container for test runs
|
||||||
|
8. **Remove SQLite deps**: Remove `better-sqlite3` from devDependencies after migration confirmed
|
||||||
|
|
||||||
## Sources
|
## Sources
|
||||||
|
|
||||||
- [framer-motion package lockfile entry] — peerDeps `react: "^18.0.0 || ^19.0.0"` confirmed (HIGH confidence, from project's `bun.lock`)
|
- [Logto official docs -- React quickstart](https://docs.logto.io/quick-starts/react) -- SDK setup, LogtoProvider config (HIGH confidence)
|
||||||
- [Motion Reorder docs](https://motion.dev/docs/react-reorder) — `Reorder.Group`, `Reorder.Item`, `useDragControls` API, `onDragEnd` pattern for persisting order (HIGH confidence)
|
- [Logto API protection -- JWT validation](https://docs.logto.io/api-protection/nodejs/express) -- jose-based middleware pattern (HIGH confidence)
|
||||||
- [Motion Changelog](https://motion.dev/changelog) — v12.37.0 actively maintained through Feb 2026 (HIGH confidence)
|
- [Logto OSS getting started](https://docs.logto.io/logto-oss/get-started-with-oss) -- Docker deployment, Postgres requirements (HIGH confidence)
|
||||||
- [@dnd-kit/core npm](https://www.npmjs.com/package/@dnd-kit/core) — v6.3.1, last published ~1 year ago, no React 19 support (HIGH confidence)
|
- [Logto @logto/react npm](https://www.npmjs.com/package/@logto/react) -- Version 4.0.13 confirmed (HIGH confidence)
|
||||||
- [dnd-kit React 19 issue #1511](https://github.com/clauderic/dnd-kit/issues/1511) — CLOSED but React 19 TypeScript issues confirmed (MEDIUM confidence)
|
- [Drizzle ORM -- Bun SQL driver](https://orm.drizzle.team/docs/connect-bun-sql) -- Native Postgres via Bun (HIGH confidence)
|
||||||
- [@dnd-kit/react roadmap discussion #1842](https://github.com/clauderic/dnd-kit/discussions/1842) — 0 maintainer replies on stability question (HIGH confidence — signals pre-1.0 risk)
|
- [Drizzle ORM -- PostgreSQL column types](https://orm.drizzle.team/docs/column-types/pg) -- pg-core schema definitions (HIGH confidence)
|
||||||
- [hello-pangea/dnd React 19 issue #864](https://github.com/hello-pangea/dnd/issues/864) — React 19 support still open as of Jan 2026 (HIGH confidence)
|
- [drizzle-kit Bun SQL issue #4122](https://github.com/drizzle-team/drizzle-orm/issues/4122) -- Known CLI limitation with Bun driver (MEDIUM confidence)
|
||||||
- [Top 5 Drag-and-Drop Libraries for React 2026](https://puckeditor.com/blog/top-5-drag-and-drop-libraries-for-react) — ecosystem overview confirming dnd-kit and hello-pangea/dnd limitations (MEDIUM confidence)
|
- [Bun S3 documentation](https://bun.com/docs/runtime/s3) -- Native S3 client, MinIO config (HIGH confidence)
|
||||||
|
- [MinIO GitHub](https://github.com/minio/minio) -- S3-compatible self-hosted storage (HIGH confidence)
|
||||||
|
- [jose GitHub](https://github.com/panva/jose) -- JWT library v6.2.2, explicit Bun support (HIGH confidence)
|
||||||
|
- [Authentik vs Zitadel comparison](https://wz-it.com/en/blog/authentik-vs-zitadel-identity-provider-comparison/) -- Auth provider analysis (MEDIUM confidence)
|
||||||
|
- [Keycloak vs Authentik vs Zitadel 2026](https://blog.houseoffoss.com/post/keycloak-vs-authentik-vs-zitadel-2026-which-open-source-login-tool-should-you-use) -- Ecosystem overview (MEDIUM confidence)
|
||||||
|
- [postgres.js npm](https://www.npmjs.com/package/postgres) -- Version 3.4.8, fallback driver (HIGH confidence)
|
||||||
|
|
||||||
---
|
---
|
||||||
*Stack research for: GearBox v1.3 -- Research & Decision Tools*
|
*Stack research for: GearBox v2.0 Platform Foundation*
|
||||||
*Researched: 2026-03-16*
|
*Researched: 2026-04-03*
|
||||||
|
|||||||
@@ -1,182 +1,203 @@
|
|||||||
# Project Research Summary
|
# Project Research Summary
|
||||||
|
|
||||||
**Project:** GearBox v1.3 — Research & Decision Tools
|
**Project:** GearBox v2.0 Platform Foundation
|
||||||
**Domain:** Gear management — candidate comparison, setup impact preview, drag-to-reorder ranking with pros/cons
|
**Domain:** Single-user to multi-user gear management and discovery platform
|
||||||
**Researched:** 2026-03-16
|
**Researched:** 2026-04-03
|
||||||
**Confidence:** HIGH
|
**Confidence:** HIGH
|
||||||
|
|
||||||
## Executive Summary
|
## Executive Summary
|
||||||
|
|
||||||
GearBox v1.3 adds three decision-support features to the existing thread detail page: side-by-side candidate comparison, setup impact preview (weight/cost delta), and drag-to-reorder candidate ranking with pros/cons annotation. All four research areas converge on the same conclusion — the existing stack is sufficient and no new dependencies are required. `framer-motion@12.37.0` (already installed) provides the `Reorder` component for drag-to-reorder, eliminating the need for `@dnd-kit` (which lacks React 19 support) or any other library. Two of the three features (comparison view and impact preview) require zero schema changes and can be built as pure client-side derived views using data already cached by `useThread()` and `useSetup()`.
|
GearBox v2.0 is a structural migration, not a feature addition. The project transforms a proven single-user gear tracker (React 19 + Hono + Drizzle + SQLite + Bun) into a multi-user platform with a global item database, structured community reviews, and public setup sharing. The research is clear on the sequencing: database migration and multi-user data scoping must come first because every other feature depends on user-owned records and Postgres. Skipping or deferring either creates cascading rework across all downstream features.
|
||||||
|
|
||||||
The recommended build sequence is dependency-driven: schema migration first (adds `sort_order`, `pros`, `cons` to `thread_candidates`), then ranking UI (uses the new columns), then comparison view and impact preview in parallel (both are schema-independent client additions). This order eliminates the risk of mid-feature migrations and ensures the comparison table can display rank, pros, and cons from day one rather than being retrofitted. The entire milestone touches 3 new files and 10 modified files — a contained, low-blast-radius changeset.
|
The recommended stack additions are conservative and well-documented: PostgreSQL 16 (required by the auth provider and for concurrent access), Bun's native S3 client against self-hosted MinIO (zero new dependencies), and an external OIDC auth provider replacing the current cookie-session system. The existing stack is validated and stays intact. The largest implementation risk is the SQLite-to-Postgres migration — it is a full schema rewrite, not an automated conversion, and it forces every service function to become async, cascading through 6 service files, 7 route files, and 19 MCP tools.
|
||||||
|
|
||||||
The primary risks are implementation-level rather than architectural. Three patterns require deliberate design before coding: (1) use `tempItems` local state alongside React Query for drag reorder to prevent the well-documented flicker bug, (2) use `sortOrder REAL` (fractional) instead of `INTEGER` to avoid bulk UPDATE writes on every drag, and (3) treat impact preview as an "add vs replace" decision — not just a pure addition — since users comparing gear are almost always replacing an existing item, not stacking one on top. All three are avoidable with upfront design; recovery cost is low but retrofitting is disruptive.
|
**Open decision — auth provider:** STACK.md recommends Logto (TypeScript-native, purpose-built for app auth, React SDK with hooks, requires only Postgres, MIT-licensed). ARCHITECTURE.md recommends Authentik (Python-based, full OIDC/OAuth2, self-hosted, requires Postgres and Redis). Both are valid. Logto integrates at the React layer via `@logto/react` and validates JWTs on the Hono backend via `jose`. Authentik integrates at the server layer via `@hono/oidc-auth` middleware and handles all session state — no React SDK needed. This decision must be resolved before the auth phase begins and affects infrastructure dependencies, React integration complexity, and future capability for proxy-mode SSO. See the Gaps section for resolution criteria.
|
||||||
|
|
||||||
## Key Findings
|
## Key Findings
|
||||||
|
|
||||||
### Recommended Stack
|
### Recommended Stack
|
||||||
|
|
||||||
Zero new dependencies are needed for this milestone. The existing stack handles all three features: Tailwind CSS for the comparison table layout, `framer-motion`'s `Reorder` component for drag ordering, Drizzle ORM + Hono + Zod for the one new write endpoint (`PATCH /api/threads/:id/candidates/reorder`), and TanStack Query for the new `useReorderCandidates` mutation. All other React Query hooks (`useThread`, `useSetup`, `useSetups`) already exist and return the data needed for comparison and impact preview without modification.
|
The existing stack (React 19, Hono, Drizzle ORM, TanStack Router/Query, Tailwind v4, Zustand, Zod, Bun) is unchanged and validated. The following are net-new additions for v2.0.
|
||||||
|
|
||||||
**Core technologies:**
|
**Core technologies:**
|
||||||
- `framer-motion@12.37.0` (Reorder component): drag-to-reorder — already installed, React 19 peerDeps confirmed in `bun.lock`, replaces any need for `@dnd-kit`
|
- **PostgreSQL 16:** Primary database — replaces SQLite for concurrent multi-user access; required by both auth provider candidates; enables full-text search for the global item catalog
|
||||||
- `drizzle-orm@0.45.1`: three new columns on `thread_candidates` (`sort_order REAL`, `pros TEXT`, `cons TEXT`) plus one new service function (`reorderCandidates`)
|
- **Drizzle ORM pg-core:** Existing ORM, different dialect — switch from `drizzle-orm/bun-sqlite` to `drizzle-orm/bun-sql` (or `postgres-js` as fallback); schema must be rewritten from scratch using `pgTable`, not migrated from `sqliteTable`
|
||||||
- Tailwind CSS v4: comparison table layout with `overflow-x-auto`, `sticky left-0` for frozen label column, `min-w-[200px]` per candidate column
|
- **Bun native S3Client + MinIO:** Zero-dependency image storage — replaces `./uploads/` local filesystem; Bun ships a native S3 client with documented MinIO compatibility; no `@aws-sdk/client-s3` needed
|
||||||
- TanStack Query v5 + existing hooks: impact preview and comparison view derived entirely from cached `useThread` + `useSetup` data — no new API endpoints on read paths
|
- **jose (^6.2.2):** JWT validation — verifies OIDC access tokens on the Hono backend via JWKS; zero dependencies, explicit Bun support; needed regardless of auth provider choice
|
||||||
- Zod v4: extend `updateCandidateSchema` with `sortOrder: z.number().finite()`, `pros: z.string().max(500).optional()`, `cons: z.string().max(500).optional()`
|
- **@logto/react (^4.0.13) OR @hono/oidc-auth:** Auth provider SDK — one of these two depending on the provider decision (see open decision above)
|
||||||
|
- **@electric-sql/pglite:** In-process Postgres for tests — replaces `bun:sqlite` in-memory test setup; Drizzle supports it via `drizzle-orm/pglite`; avoids Docker dependency in unit/integration tests
|
||||||
|
- **postgres (postgres.js, ^3.4.8):** Dev dependency only — required for `drizzle-kit` CLI (`db:generate`, `db:push`) due to known issue #4122 where drizzle-kit does not support the Bun native SQL driver
|
||||||
|
|
||||||
**What NOT to use:**
|
**Infrastructure additions:** Docker Compose running Postgres + auth provider + MinIO alongside the Bun app. The app itself continues to run on bare Bun for HMR.
|
||||||
- `@dnd-kit/core@6.3.1` — no React 19 support, unmaintained for ~1 year
|
|
||||||
- `@dnd-kit/react@0.3.2` — pre-1.0, no maintainer response on stability
|
|
||||||
- `@hello-pangea/dnd@18.0.1` — `peerDep react: "^18.0.0"` only, stale
|
|
||||||
- Any third-party comparison table component — custom Tailwind table is trivial and design-consistent
|
|
||||||
|
|
||||||
### Expected Features
|
### Expected Features
|
||||||
|
|
||||||
All five v1.3 features are confirmed as P1 (must-have for this milestone). No existing gear management tool (LighterPack, GearGrams, OutPack) has comparison view, delta preview, or ranking — these are unmet-need differentiators adapted from e-commerce comparison UX to the gear domain.
|
**Must have for v2.0 launch (P1):**
|
||||||
|
- External auth provider integration — nothing works without multi-user identity
|
||||||
|
- PostgreSQL migration — concurrent access and auth provider dependency
|
||||||
|
- Multi-user data model (userId FK on items, categories, threads, setups, settings) — data isolation foundation
|
||||||
|
- User profiles (minimal: display name, avatar, bio, public setups list) — required for attribution on shared content
|
||||||
|
- Setup visibility controls (public/private toggle, default private) — table stakes for any sharing feature
|
||||||
|
- Public setup detail pages — shareable read-only view with item list, totals, creator attribution
|
||||||
|
- Global item database with seed data — canonical product catalog enabling reviews and aggregation
|
||||||
|
- Link personal items to global items — the bridge enabling owner counts, crowd specs, and weight data
|
||||||
|
- Search global items — full-text search powering item linking and discovery browsing
|
||||||
|
- Structured reviews (overall + dimension ratings, no freeform text) — community intelligence layer
|
||||||
|
- Item detail pages (aggregated specs, owner count, average ratings) — integration hub for all platform data
|
||||||
|
- Discovery browse page (recent public setups, recently reviewed items, popular gear) — entry point for community value
|
||||||
|
|
||||||
**Must have (table stakes):**
|
**Should have after validation (P2):**
|
||||||
- Side-by-side comparison view — users juggling 3+ candidates mentally across cards expect tabular layout; NNGroup and Smashing Magazine confirm this is the standard for comparison contexts
|
- Crowd-verified specs display (manufacturer vs. community-measured weight, needs 3+ owners per item)
|
||||||
- Weight and cost delta per candidate — gear apps always display weight prominently; delta is more actionable than raw weight
|
- Setup composition insights ("commonly paired with" co-occurrence analysis)
|
||||||
- Setup selector for impact preview — required to contextualize the delta; `useSetups()` already exists
|
- Planning thread global item integration (candidates auto-populate from global DB)
|
||||||
|
- Copy/fork public setups (one-click template from public setups)
|
||||||
|
- Popular gear rankings by category (most owned, highest rated)
|
||||||
|
|
||||||
**Should have (differentiators):**
|
**Defer to v3+:**
|
||||||
- Drag-to-rank ordering — makes priority explicit without numeric input; no competitor has this in the gear domain; requires `sort_order` schema migration
|
- Freeform reviews with moderation (explicitly deferred until moderation infrastructure exists)
|
||||||
- Per-candidate pros/cons fields — structured decision rationale; stored as newline-delimited text (renders as bullets in comparison view); requires `pros`/`cons` schema migration
|
- Comments on setups (moderation burden)
|
||||||
|
- Follow users / activity feed (social-network complexity, against the discovery-first principle)
|
||||||
|
- OAuth / social login (after external auth is stable)
|
||||||
|
|
||||||
**Defer (v2+):**
|
**Anti-features to reject explicitly:** real-time collaborative setups, marketplace/buy-sell, AI gear recommendations, wiki-style open item editing, gamification, Instagram-style infinite scroll feed.
|
||||||
- Classification-aware impact breakdown (base/worn/consumable) — data available but UI complexity high; flat delta covers 90% of use case
|
|
||||||
- Rank badge on card grid — useful but low urgency; add when users express confusion
|
|
||||||
- Mobile-optimized comparison view (swipe between candidates) — horizontal scroll works for now
|
|
||||||
- Comparison permalink — requires auth/multi-user work not in scope for v1
|
|
||||||
|
|
||||||
**Anti-features (explicitly rejected):**
|
|
||||||
- Custom comparison attributes — complexity trap, rejected in PROJECT.md
|
|
||||||
- Score/rating calculation — opaque algorithms distrust; manual ranking expresses user preference better
|
|
||||||
- Cross-thread comparison — candidates are decision-scoped; different categories are not apples-to-apples
|
|
||||||
|
|
||||||
### Architecture Approach
|
### Architecture Approach
|
||||||
|
|
||||||
All three features integrate on the `/threads/$threadId` route with no impact on other routes. The comparison view and impact preview are pure client-side derived views using data already in the React Query cache — no new API endpoints on read paths. The only new server-side endpoint is `PATCH /:id/candidates/reorder` which accepts `{ orderedIds: number[] }` and applies a transactional bulk-update in `thread.service.ts`. The `uiStore` (Zustand) gains two new fields: `compareMode: boolean` and `impactSetupId: number | null`, consistent with existing UI-state-only patterns.
|
The v2.0 architecture is a layered structural migration where each layer depends on the one below it: Postgres first (database), then user identity (auth), then data scoping (userId on all entities), then the global item catalog, then community features on top. Every existing service file gains a `userId` parameter and becomes `async` — this is a mechanical but wide-ranging change touching 6 services, 7 routes, 19 MCP tools, and all tests. The component topology stays the same (Hono routes -> services -> Drizzle); only the wiring within each layer changes.
|
||||||
|
|
||||||
**Major components:**
|
**Major components:**
|
||||||
1. `CandidateCompare.tsx` (new) — side-by-side table; columns = candidates, rows = attributes; pure presentational, derives deltas from `thread.candidates[]`; `overflow-x-auto` for narrow viewports; sticky label column
|
1. **Database layer (Postgres + pg-core)** — Full schema rewrite; all entity tables gain userId FK; new `globalItems` and `reviews` tables; sessions table removed; `categories` unique constraint changes to composite `(userId, name)`
|
||||||
2. `SetupImpactRow.tsx` (new) — delta display (`+Xg / +$Y`); reads from `useSetup(impactSetupId)` data passed as props; handles null weight case explicitly
|
2. **Auth middleware (OIDC)** — Replaces `requireAuth`; resolves OIDC subject to local userId via `getOrCreateUser`; keeps API key system intact for MCP and programmatic access; sessions table deleted (OIDC handles state via signed JWT cookies)
|
||||||
3. `Reorder.Group` / `Reorder.Item` (framer-motion, no new file) — wraps `CandidateCard` list in `$threadId.tsx`; `onReorder` updates local `orderedCandidates` state; `onDragEnd` fires `useReorderCandidates` mutation
|
3. **User-scoped services** — All existing services gain `userId` parameter and async signature; 4 new services added: `globalItem.service`, `review.service`, `profile.service`, `discover.service`
|
||||||
4. `CandidateCard.tsx` (modified) — gains `rank` prop (gold/silver/bronze badge for top 3), pros/cons indicator icons; `isActive={false}` when rendered inside comparison view
|
4. **Image storage layer (MinIO via Bun S3Client)** — Replaces filesystem writes; abstraction interface allows local dev vs. S3 production swap; existing `/uploads/*` static route replaced by proxy or presigned URL handler
|
||||||
5. `CandidateForm.tsx` (modified) — gains `pros`/`cons` textarea fields below existing Notes field
|
5. **Global item catalog** — Separate `globalItems` table (not the user `items` table); admin-seeded initially; user items optionally reference global items via nullable `globalItemId` FK; reviews and owner counts attach to global items, not user items
|
||||||
|
6. **Public content layer** — Setup `isPublic` flag; public profile pages; discovery queries over public content with indexes on `owner_count`, `(is_public, updated_at)`, and `global_item_id`
|
||||||
**Key patterns to follow:**
|
|
||||||
- `tempItems` local state alongside React Query for drag reorder — prevents the documented flicker bug; do not use `setQueryData` alone
|
|
||||||
- Client-computed derived data from cached queries — no new read endpoints (anti-pattern: building `GET /api/threads/:id/compare` or `GET /api/threads/:id/impact`)
|
|
||||||
- `uiStore` for cross-panel persistent UI flags only — no server data in Zustand
|
|
||||||
- Resolved-thread guard — `thread.status === "resolved"` must disable drag handles and block the reorder endpoint (data integrity requirement, not just UX)
|
|
||||||
|
|
||||||
### Critical Pitfalls
|
### Critical Pitfalls
|
||||||
|
|
||||||
1. **Drag flicker from `setQueryData`-only optimistic update** — use `tempItems` local state (`useState<Candidate[] | null>(null)`); render from `tempItems ?? queryData.candidates`; clear on mutation `onSettled`. Must be designed before building the drag UI, not retrofitted. (PITFALLS.md Pitfall 1)
|
1. **Missing userId filters leak data between users** — Any service function not updated to filter by `userId` returns all users' data across 30+ query sites. Prevention: use `userId NOT NULL` in schema so TypeScript compiler errors guide updates; add Postgres Row-Level Security as a safety net; write cross-user isolation tests per entity (create as User A, query as User B, assert empty results).
|
||||||
|
|
||||||
2. **Integer `sortOrder` causes bulk writes** — use `REAL` (float) type for `sort_order` column with fractional indexing so only the moved item requires a single UPDATE. With 8+ candidates and rapid dragging, integer bulk updates produce visible latency and hold a SQLite write lock. Start values at 1000 with 1000-unit gaps. (PITFALLS.md Pitfall 2)
|
2. **Drizzle schema rewrite is a replacement, not a migration** — `sqlite-core` and `pg-core` are incompatible; `real()` in Postgres is 4-byte float vs. SQLite's 8-byte (use `doublePrecision()` for weight values); timestamps change from integer epoch to native `timestamp`; all service functions must become async (`.get()` and `.all()` are sync SQLite methods, Postgres uses `await`). Prevention: rewrite schema from scratch, update all `.get()` / `.all()` calls, run full test suite against Postgres.
|
||||||
|
|
||||||
3. **Impact preview shows wrong delta (add vs replace)** — default to "replace" mode when a setup item exists in the same category as the thread; default to "add" mode when no category match. Pure-addition delta misleads users: a 500g candidate replacing an 800g item shows "+500g" instead of "-300g". The distinction must be designed into the service layer, not retrofitted. (PITFALLS.md Pitfall 6)
|
3. **Test infrastructure collapses during DB switch** — `createTestDb()` uses `bun:sqlite` in-memory SQLite. After the switch, every test needs Postgres. Prevention: adopt PGlite (`@electric-sql/pglite`) for unit/integration tests immediately; never let the SQLite test setup coexist with Postgres production code past the migration sprint.
|
||||||
|
|
||||||
4. **Comparison/rank on resolved threads** — `thread.status === "resolved"` must hide drag handles, disable rank mutation, and show a read-only summary. The reorder API route must return 400 for resolved threads. This is a data integrity issue, not just UX. (PITFALLS.md Pitfall 8)
|
4. **Auth migration breaks sessions, API keys, and MCP** — Switching to external OIDC touches the login flow, session management, API key ownership, MCP authentication, E2E test setup, and the onboarding flow. Prevention: keep API keys in the local database (do not delegate to the auth provider); maintain a local `users` table with `externalId` (OIDC subject) FK; keep `apiKeys` table with `userId` FK to local users; update E2E tests to authenticate via API keys.
|
||||||
|
|
||||||
5. **Test helper schema drift** — every schema change must update `tests/helpers/db.ts` in the same commit. Run `bun test` immediately after schema + helper update. Missing this produces `SqliteError: no such column` failures. (PITFALLS.md Pitfall 7)
|
5. **Global item database creates a data model fork if built wrong** — An `isGlobal` flag or `NULL userId` on the user `items` table makes queries unmaintainable and blurs permission boundaries. Prevention: separate `globalItems` table from day one; user items get a nullable `globalItemId` FK; reviews and owner counts attach to `globalItems` only.
|
||||||
|
|
||||||
|
6. **Existing data has no owner after migration** — Current SQLite data has no `userId`. Adding `userId NOT NULL` breaks migration if existing rows are not assigned an owner first. Prevention: data migration script must create the original user first, assign all existing data to that userId, then enforce NOT NULL — never make `userId` permanently nullable as a migration workaround.
|
||||||
|
|
||||||
## Implications for Roadmap
|
## Implications for Roadmap
|
||||||
|
|
||||||
Based on research, a 4-phase structure is recommended with a clear dependency order: schema foundation first, ranking second (consumes new columns), then comparison view and impact preview as sequential client-only phases.
|
The dependency chain is strict: Postgres -> Auth -> Multi-user scoping -> Global items -> Community features. Attempting to parallelize across this chain creates rework. Suggested phase structure:
|
||||||
|
|
||||||
### Phase 1: Schema Foundation + Pros/Cons Fields
|
### Phase 1: Database Migration (SQLite to PostgreSQL)
|
||||||
|
**Rationale:** Everything else depends on Postgres. Auth providers require it. Concurrent access requires it. Full-text search for the global item catalog requires it. Must be done first and tested completely before any feature work begins.
|
||||||
|
**Delivers:** Postgres running locally and in CI; schema rewritten in pg-core; all service functions async; PGlite test infrastructure replacing `bun:sqlite`; one-time data migration script for existing SQLite data; drizzle.config.ts updated; all PRAGMA statements removed
|
||||||
|
**Addresses:** Postgres migration (FEATURES.md P1)
|
||||||
|
**Avoids:** Pitfall 3 (schema rewrite), Pitfall 4 (test infrastructure), Pitfall 10 (SQLite-specific patterns), Pitfall 12 (existing data ownership)
|
||||||
|
**Research flag:** Well-documented migration pattern. STACK.md and ARCHITECTURE.md agree on the approach. No additional research needed — pitfalls are comprehensively documented with GearBox-specific code references.
|
||||||
|
|
||||||
**Rationale:** All ranking and pros/cons work shares a schema migration. Batching `sort_order`, `pros`, and `cons` into a single migration avoids multiple ALTER TABLE runs and ensures the test helper is updated once. Pros/cons field UI is low-complexity (two textareas in `CandidateForm`) and can be delivered immediately after the migration, making candidates richer before ranking is built.
|
### Phase 2: Authentication Provider Integration
|
||||||
**Delivers:** `sort_order REAL NOT NULL DEFAULT 0`, `pros TEXT`, `cons TEXT` on `thread_candidates`; pros/cons visible in candidate edit panel; `CandidateCard` shows pros/cons indicator icons; `tests/helpers/db.ts` updated; Zod schemas extended with 500-char length caps
|
**Rationale:** User identity must exist before userId can be added to any table. This phase also resolves the open Logto vs. Authentik decision.
|
||||||
**Addresses:** Side-by-side comparison row data (pros/cons), drag-to-rank prerequisite (sort_order)
|
**Delivers:** External auth provider running in Docker; OIDC middleware on Hono; local `users` table with `externalId` (OIDC subject); API key system preserved and userId-scoped; E2E tests updated to use API key authentication; onboarding flow replaced with auth provider registration; MCP auth updated
|
||||||
**Avoids:** Test helper schema drift (Pitfall 7), pros/cons as unstructured blobs (Pitfall 5 — newline-delimited format chosen at schema time)
|
**Uses:** `jose` (JWT validation), `@logto/react` or `@hono/oidc-auth` depending on provider decision, Docker Compose
|
||||||
|
**Avoids:** Pitfall 5 (auth breaks sessions/keys/MCP); integration gotcha (keep local users table with externalId, do not remove it)
|
||||||
|
**Research flag:** NEEDS RESOLUTION before this phase can be planned — the Logto vs. Authentik decision. See Gaps section for resolution criteria. Once the provider is chosen, integration patterns are well-documented in official docs.
|
||||||
|
|
||||||
### Phase 2: Drag-to-Reorder Candidate Ranking
|
### Phase 3: Multi-User Data Model
|
||||||
|
**Rationale:** With user identity established, all entity tables can be scoped. This is the highest-risk phase because it touches every query in the codebase and is where data leaks occur if anything is missed.
|
||||||
|
**Delivers:** `userId` NOT NULL on items, categories, threads, setups, settings, apiKeys; composite unique on `(userId, name)` for categories; `isPublic` boolean on setups; `resolveThread` propagates userId to newly created items; all service functions filter by userId; cross-user isolation tests passing per entity; Postgres RLS policies active; MCP tools user-scoped; settings migrated to per-user
|
||||||
|
**Addresses:** Multi-user data model, setup visibility controls, user profile data model (FEATURES.md P1)
|
||||||
|
**Avoids:** Pitfall 1 (missing userId filters), Pitfall 2 (category uniqueness), Pitfall 8 (thread resolution userId), Pitfall 9 (public content defaults to private), Pitfall 11 (setup sync race conditions)
|
||||||
|
**Research flag:** No additional research needed — pitfall documentation is comprehensive with specific per-table and per-function guidance for the existing GearBox codebase.
|
||||||
|
|
||||||
**Rationale:** Depends on Phase 1 (`sort_order` column must exist). Schema work is done; this phase is pure service + client. The `tempItems` pattern must be implemented correctly from the start to prevent the React Query flicker bug.
|
### Phase 4: Image Storage Migration (MinIO)
|
||||||
**Delivers:** `reorderCandidates` service function (transactional loop); `PATCH /api/threads/:id/candidates/reorder` endpoint with thread ownership validation; `useReorderCandidates` mutation hook; `Reorder.Group` / `Reorder.Item` in thread detail route; rank badge (gold/silver/bronze) on `CandidateCard`; resolved-thread guard (no drag handles, API returns 400 for resolved)
|
**Rationale:** Move image storage before public profiles and discovery ship. Once images are served to unauthenticated users at scale, the local filesystem approach fails. Better to resolve this before public content launches.
|
||||||
**Uses:** `framer-motion@12.37.0` Reorder API (already installed), Drizzle ORM transaction, fractional `sort_order REAL` arithmetic (single UPDATE per drag)
|
**Delivers:** MinIO running in Docker; Bun native S3Client configured; existing `./uploads/` migrated to MinIO bucket; image service updated; proxy route for S3 reads; MCP `upload_image_from_url` tool updated; image storage abstraction (local filesystem for dev, S3 for production)
|
||||||
**Avoids:** dnd-kit flicker (Pitfall 1 — `tempItems` pattern), bulk integer writes (Pitfall 2 — REAL type), resolved-thread corruption (Pitfall 8)
|
**Uses:** Bun native S3Client (built-in, no install), MinIO Docker container
|
||||||
|
**Avoids:** Pitfall 7 (image URL breakage after storage migration)
|
||||||
|
**Research flag:** Standard pattern. Bun S3Client docs and MinIO compatibility are well-documented. No research needed.
|
||||||
|
|
||||||
### Phase 3: Side-by-Side Comparison View
|
### Phase 5: Global Item Database
|
||||||
|
**Rationale:** The global item catalog is the second major foundation. Reviews, item detail pages, owner counts, and discovery all depend on canonical product records existing before those features can be built.
|
||||||
|
**Delivers:** `globalItems` table in pg-core; admin seeding workflow with 200-500 initial items across core categories; nullable `globalItemId` FK on user items; item linking flow in collection UI; full-text search via Postgres `tsvector`; `GET /api/global-items` endpoints (public, no auth); `globalItem.service.ts`
|
||||||
|
**Addresses:** Global item database, link personal items to global items, search global items (FEATURES.md P1)
|
||||||
|
**Avoids:** Pitfall 6 (data model fork — separate `globalItems` table, not a flag on user items)
|
||||||
|
**Research flag:** May benefit from targeted research on Postgres full-text search (`tsvector`/`tsquery`) configuration — specifically index design and query tuning for the expected catalog size and query patterns (brand + model name search). Schema is specified; FTS tuning is domain-dependent.
|
||||||
|
|
||||||
**Rationale:** No schema dependency — can technically be built before Phase 2, but is most useful when rank, pros, and cons are already in the data model so the comparison table shows the full picture from day one. Pure client-side presentational component; no API changes.
|
### Phase 6: Community Features (Reviews, Profiles, Discovery)
|
||||||
**Delivers:** `CandidateCompare.tsx` component; "Compare" toggle button in thread header; `compareMode` in `uiStore`; comparison table with sticky label column, horizontal scroll, weight/price relative deltas (lightest/cheapest candidate highlighted); responsive at 768px viewport; read-only summary for resolved threads
|
**Rationale:** With the global item catalog seeded and users scoped, community features can be built on top. These three feature areas are bundled because they form the public-facing value proposition together — profiles without discovery, or discovery without content, delivers nothing meaningful to users.
|
||||||
**Implements:** Client-computed derived data pattern — data from `useThread()` cache; `Math.min` across candidates for relative delta; `formatWeight`/`formatPrice` for display
|
**Delivers:** Structured reviews (overall + dimension ratings, one per user per global item, no freeform text); user public profiles (display name, avatar, bio, joined date, public setups list); public setup detail pages; discovery browse page (recent public setups, recently reviewed items, popular items by owner count); item detail pages with aggregated stats (owner count, average ratings, crowd-verified weight)
|
||||||
**Avoids:** Comparison breaking at narrow widths (Pitfall 4 — `overflow-x-auto` + `min-w-[200px]`), comparison visible on resolved threads (Pitfall 8), server endpoint for comparison deltas (architecture anti-pattern)
|
**Addresses:** Structured reviews, user profiles, public setup pages, item detail pages, discovery browse (FEATURES.md P1)
|
||||||
|
**Uses:** Review schema with composite unique `(userId, globalItemId)`; denormalized `avgRating` and `ownerCount` on globalItems; cursor-paginated discovery queries; indexes on `(is_public, updated_at)` and `owner_count`
|
||||||
### Phase 4: Setup Impact Preview
|
**Avoids:** Pitfall 9 (private by default enforced in all discovery queries); N+1 query trap in feed (use joins, not per-item queries)
|
||||||
|
**Research flag:** Discovery feed pagination (cursor vs. offset) and feed composition are well-documented standard patterns at this scale. No additional research needed for v2.0.
|
||||||
**Rationale:** No schema dependency. Easiest to build last because the comparison view UI (Phase 3) already establishes the thread header area where the setup selector lives. Both add-mode and replace-mode deltas must be designed here to avoid the misleading pure-addition delta.
|
|
||||||
**Delivers:** Setup selector dropdown in thread header (`useSetups()` data); `SetupImpactRow.tsx` component; `impactSetupId` in `uiStore`; add-mode delta and replace-mode delta (auto-defaults to replace when same-category item exists in setup); null weight guard ("-- (no weight data)" not "+0g"); unit-aware display via `useWeightUnit()` / `useCurrency()`
|
|
||||||
**Uses:** Existing `useSetup(id)` hook (no new API), existing `formatWeight` / `formatPrice` formatters, `categoryId` on thread for replacement item detection
|
|
||||||
**Avoids:** Stale data in impact preview (Pitfall 3 — reactive `useQuery` for setup data), wrong delta from add-vs-replace confusion (Pitfall 6), null weight treated as 0 (integration gotcha), server endpoint for delta calculation (architecture anti-pattern)
|
|
||||||
|
|
||||||
### Phase Ordering Rationale
|
### Phase Ordering Rationale
|
||||||
|
|
||||||
- Phase 1 before all others: SQLite schema changes batched into a single migration; test helper updated once; pros/cons in edit panel adds value immediately without waiting for the comparison view
|
- Phases 1-3 form an unbreakable dependency chain: each phase is a prerequisite for the next. No parallelization is possible without creating rework.
|
||||||
- Phase 2 before Phase 3: rank data (sort order, rank badge) is more valuable displayed in the comparison table than in the card grid alone; building the comparison view after ranking ensures the table is complete on first delivery
|
- Phase 4 (images) is inserted before community features because public profiles and discovery serve images to unauthenticated users — local filesystem is not viable at that point.
|
||||||
- Phase 3 before Phase 4: comparison view establishes the thread header chrome (toggle button area) where the setup selector in Phase 4 will live; building header UI in Phase 3 reduces Phase 4 scope
|
- Phase 5 (global items) must precede Phase 6 (community features) because reviews require global item records to attach to; the dependency is one-directional.
|
||||||
- Phases 3 and 4 are technically independent and could parallelize, but sequencing them keeps the thread detail header changes contained to one phase at a time
|
- Phases 5 and 6 could be split across releases (global items + linking as v2.0, community features as v2.1) if schedule pressure exists — the global item catalog delivers standalone value through item linking even before reviews exist.
|
||||||
|
|
||||||
### Research Flags
|
### Research Flags
|
||||||
|
|
||||||
Phases that need careful plan review before execution (not full research-phase, but plan must address specific design decisions):
|
Needs deeper research during planning:
|
||||||
- **Phase 2:** The `tempItems` local state pattern and fractional `sort_order` arithmetic are non-obvious. The PLAN.md must spell these out explicitly before coding. PITFALLS.md Pitfall 1 and Pitfall 2 must be addressed in the plan, not discovered during implementation.
|
- **Phase 2 (Auth):** Auth provider decision (Logto vs. Authentik) must be resolved before this phase is planned. The integration pattern differs significantly between the two options — React SDK + backend JWT validation (Logto) vs. server-side middleware only (Authentik).
|
||||||
- **Phase 4:** The add-vs-replace distinction requires deliberate design (which mode is default, how replacement item is detected by category, how null weight is surfaced). PITFALLS.md Pitfall 6 must be resolved in the plan before the component is built.
|
- **Phase 5 (Global Items):** Postgres full-text search index design and query tuning for the global item catalog. The schema is specified; the FTS configuration (`tsvector` column type, GIN index, query parser) needs validation against expected query patterns and initial catalog size.
|
||||||
|
|
||||||
Phases with standard patterns (can skip `/gsd:research-phase`):
|
Phases with standard patterns (skip research-phase):
|
||||||
- **Phase 1:** Standard Drizzle migration + Zod schema extension; established patterns in the codebase; ARCHITECTURE.md provides exact column definitions
|
- **Phase 1 (Database):** Migration path is thoroughly documented across ARCHITECTURE.md, PITFALLS.md, and STACK.md. Per-file implementation checklist is complete.
|
||||||
- **Phase 3:** Pure presentational component; Tailwind comparison table is well-documented; ARCHITECTURE.md provides complete component structure, props interface, and delta calculation code
|
- **Phase 3 (Multi-user):** Per-table userId requirements are fully specified. Pitfall checklist covers all edge cases including MCP tools, thread resolution, settings, and the threadCandidates join path.
|
||||||
|
- **Phase 4 (Images):** Bun S3Client + MinIO is documented by the Bun team. Standard proxy-then-presigned-URL migration path is specified.
|
||||||
|
- **Phase 6 (Community):** Schema, services, and query patterns are fully specified in ARCHITECTURE.md. No novel patterns.
|
||||||
|
|
||||||
## Confidence Assessment
|
## Confidence Assessment
|
||||||
|
|
||||||
| Area | Confidence | Notes |
|
| Area | Confidence | Notes |
|
||||||
|------|------------|-------|
|
|------|------------|-------|
|
||||||
| Stack | HIGH | Verified from `bun.lock` (framer-motion React 19 peerDeps confirmed); dnd-kit abandonment verified via npm + GitHub; Motion Reorder API verified via motion.dev docs |
|
| Stack | HIGH | All recommendations backed by official docs. Known issue #4122 (drizzle-kit + Bun SQL) is documented with a clear workaround. The Logto vs. Authentik disagreement is an open decision requiring resolution, not a confidence gap — both options are well-validated. |
|
||||||
| Features | HIGH | Codebase analysis confirmed no rank/pros/cons columns in existing schema; NNGroup + Smashing Magazine for comparison UX patterns; competitor analysis (LighterPack, GearGrams, OutPack) confirmed feature gap |
|
| Features | HIGH | Competitor analysis is comprehensive (LighterPack, GearGrams, Trailspace, MyGear). Feature dependency chain is fully mapped. P1/P2/P3 prioritization is grounded in implementation cost and dependency analysis. |
|
||||||
| Architecture | HIGH | Full integration map derived from direct codebase analysis; build order confirmed by column dependency graph; all changed files enumerated (3 new, 10 modified); complete code patterns provided |
|
| Architecture | HIGH | Based on direct codebase analysis of GearBox v1.4 plus official Drizzle and Hono docs. Component-level change inventory is complete (new files, modified files, removed files enumerated). Data flow diagrams are concrete and code-level. |
|
||||||
| Pitfalls | HIGH | dnd-kit flicker: verified in GitHub Discussion #1522 and Issue #921; fractional indexing: verified via steveruiz.me and fractional-indexing library; comparison UX: Baymard Institute and NNGroup |
|
| Pitfalls | HIGH | 12 pitfalls, each with specific GearBox v1.4 codebase context (file names, function names, column names, specific patterns). Confidence is high because research analyzed the actual codebase, not generic migration advice. |
|
||||||
|
|
||||||
**Overall confidence:** HIGH
|
**Overall confidence:** HIGH
|
||||||
|
|
||||||
### Gaps to Address
|
### Gaps to Address
|
||||||
|
|
||||||
- **Impact preview add-vs-replace UX:** Research establishes that both modes are needed and when to default to each (same-category item in setup = replace mode). The exact affordance — dropdown to select which item is replaced vs. automatic category matching — is not fully specified. Recommendation: auto-match by category with a "change" link to override. Decide during Phase 4 planning.
|
- **Open decision — auth provider (Logto vs. Authentik):** Must be resolved before Phase 2 planning begins. Resolution criteria: (1) If the project plans to use the auth provider for infrastructure SSO beyond the GearBox app (Portainer, Grafana, Gitea, etc.), choose Authentik — it handles proxy-mode SSO that Logto does not. (2) If GearBox is the only app needing auth, choose Logto — simpler infrastructure (no Redis dependency), React SDK eliminates manual OIDC redirect handling, TypeScript-native. STACK.md's Logto recommendation is correct for the app-auth-only use case.
|
||||||
- **Comparison view maximum candidate count:** Research recommends 3-4 max for usability. GearBox has no current limit on candidates per thread. Whether to enforce a hard display limit (hide additional candidates behind "show more") or allow unrestricted horizontal scroll should be decided during Phase 3 planning.
|
|
||||||
- **Sort order initialization for existing candidates:** When the migration runs, existing `thread_candidates` rows get `sort_order = 0` (default). Phase 1 plan must specify whether to initialize existing candidates with spaced values (e.g., 1000, 2000, 3000) at migration time or accept that all existing rows start at 0 and rely on first drag to establish order.
|
- **Review dimension configuration tension:** FEATURES.md specifies "3-5 dimension ratings per product category, admin-configurable" (flexible `reviewDimensions` table with `categoryId` FK). ARCHITECTURE.md uses hardcoded columns (`weightRating`, `durabilityRating`, `valueRating`). For v2.0, use the hardcoded column approach (simpler, no dimension management UI needed). The flexible schema is a v2.x concern. This must be noted explicitly in Phase 6 planning to prevent scope creep.
|
||||||
|
|
||||||
|
- **E2E test authentication strategy post-auth migration:** PITFALLS.md recommends switching E2E tests to API key authentication after auth moves external. The mechanism for creating a test user in the new auth system (direct Postgres insert bypassing the OIDC provider vs. auth provider admin API) needs to be decided during Phase 2 planning.
|
||||||
|
|
||||||
## Sources
|
## Sources
|
||||||
|
|
||||||
### Primary (HIGH confidence)
|
### Primary (HIGH confidence)
|
||||||
- `bun.lock` (project lockfile) — framer-motion v12.37.0 peerDeps `"react: ^18.0.0 || ^19.0.0"` confirmed
|
- GearBox v1.4 codebase — Direct analysis of `src/db/schema.ts`, service files, auth middleware, MCP server, test helpers, `db/index.ts`, E2E seed (direct codebase reference)
|
||||||
- [Motion Reorder docs](https://motion.dev/docs/react-reorder) — `Reorder.Group`, `Reorder.Item`, `onDragEnd` API
|
- [Logto official docs — React quickstart](https://docs.logto.io/quick-starts/react) — SDK setup, LogtoProvider config
|
||||||
- [dnd-kit Discussion #1522](https://github.com/clauderic/dnd-kit/discussions/1522) — `tempItems` solution for React Query cache flicker
|
- [Logto API protection — JWT validation](https://docs.logto.io/api-protection/nodejs/express) — jose-based middleware pattern
|
||||||
- [dnd-kit Issue #921](https://github.com/clauderic/dnd-kit/issues/921) — root cause of state lifecycle mismatch
|
- [Logto OSS getting started](https://docs.logto.io/logto-oss/get-started-with-oss) — Docker deployment, Postgres requirements
|
||||||
- [Fractional Indexing — steveruiz.me](https://www.steveruiz.me/posts/reordering-fractional-indices) — why float sort keys beat integer reorder for databases
|
- [Drizzle ORM — Bun SQL driver](https://orm.drizzle.team/docs/connect-bun-sql) — Native Postgres via Bun
|
||||||
- [Baymard Institute: Comparison Tool Design](https://baymard.com/blog/user-friendly-comparison-tools) — sticky headers, horizontal scroll, minimum column width
|
- [Drizzle ORM — PostgreSQL column types](https://orm.drizzle.team/docs/column-types/pg) — pg-core schema definitions
|
||||||
- [NNGroup: Comparison Tables](https://www.nngroup.com/articles/comparison-tables/) — information architecture, anti-patterns
|
- [Bun S3 documentation](https://bun.com/docs/runtime/s3) — Native S3 client, MinIO config
|
||||||
- [Smashing Magazine: Feature Comparison Table](https://www.smashingmagazine.com/2017/08/designing-perfect-feature-comparison-table/) — table layout patterns
|
- [jose GitHub](https://github.com/panva/jose) — JWT library v6.2.2, explicit Bun support
|
||||||
- GearBox codebase direct analysis (`src/db/schema.ts`, `src/server/services/`, `src/client/hooks/`, `tests/helpers/db.ts`) — confirmed existing patterns, missing columns, integration points
|
- [postgres.js npm](https://www.npmjs.com/package/postgres) — v3.4.8, fallback driver
|
||||||
|
|
||||||
### Secondary (MEDIUM confidence)
|
### Secondary (MEDIUM confidence)
|
||||||
- [@dnd-kit/core npm](https://www.npmjs.com/package/@dnd-kit/core) — v6.3.1 last published ~1 year ago, no React 19
|
- [drizzle-kit Bun SQL issue #4122](https://github.com/drizzle-team/drizzle-orm/issues/4122) — Known CLI limitation with Bun driver
|
||||||
- [dnd-kit React 19 issue #1511](https://github.com/clauderic/dnd-kit/issues/1511) — CLOSED but React 19 TypeScript issues confirmed
|
- [Authentik vs Zitadel comparison](https://wz-it.com/en/blog/authentik-vs-zitadel-identity-provider-comparison/) — Auth provider tradeoff analysis
|
||||||
- [@dnd-kit/react roadmap discussion #1842](https://github.com/clauderic/dnd-kit/discussions/1842) — 0 maintainer replies; pre-1.0 risk signal
|
- [Keycloak vs Authentik vs Zitadel 2026](https://blog.houseoffoss.com/post/keycloak-vs-authentik-vs-zitadel-2026-which-open-source-login-tool-should-you-use) — Ecosystem overview
|
||||||
- [hello-pangea/dnd React 19 issue #864](https://github.com/hello-pangea/dnd/issues/864) — still open as of Jan 2026
|
- [LighterPack](https://lighterpack.com/), [GearGrams](https://www.geargrams.com/), [Trailspace](https://www.trailspace.com/), [MyGear](https://mygear.world/) — Competitor feature analysis
|
||||||
- [BrightCoding dnd-kit deep dive (2025)](https://www.blog.brightcoding.dev/2025/08/21/the-ultimate-drag-and-drop-toolkit-for-react-a-deep-dive-into-dnd-kit/) — react-beautiful-dnd abandoned; dnd-kit current standard but React 19 gap confirmed
|
- [Multi-tenant architecture guide (WorkOS)](https://workos.com/blog/developers-guide-saas-multi-tenant-architecture) — Multi-user data isolation patterns
|
||||||
- [TrailsMag: Leaving LighterPack](https://trailsmag.net/blogs/hiker-box/ultralight-the-gear-tracking-app-i-m-leaving-lighterpack-for) — LighterPack feature gap analysis
|
- [SQLite to PostgreSQL migration pitfalls (Open WebUI)](https://github.com/open-webui/open-webui/discussions/21609) — Migration risk validation
|
||||||
- [Contentsquare: Comparing products UX](https://contentsquare.com/blog/comparing-products-design-practices-to-help-your-users-avoid-fragmented-comparison-7/) — fragmented comparison pitfalls
|
- [How to migrate from SQLite to PostgreSQL (Render)](https://render.com/articles/how-to-migrate-from-sqlite-to-postgresql) — Data migration script patterns
|
||||||
|
|
||||||
### Tertiary (LOW confidence)
|
### Tertiary (LOW confidence)
|
||||||
- [Fractional Indexing SQLite library](https://github.com/sqliteai/fractional-indexing) — implementation reference for lexicographic sort keys (pattern reference only; direct float arithmetic sufficient for this use case)
|
- [Drizzle ORM PostgreSQL best practices 2025 (GitHub Gist)](https://gist.github.com/productdevbook/7c9ce3bbeb96b3fabc3c7c2aa2abc717) — Schema patterns (validate against official docs during implementation)
|
||||||
- [Top 5 Drag-and-Drop Libraries for React 2026](https://puckeditor.com/blog/top-5-drag-and-drop-libraries-for-react) — ecosystem overview confirming dnd-kit and hello-pangea/dnd limitations
|
- [GetStream Social Feed Architecture](https://getstream.io/blog/social-media-feed/) — Feed implementation patterns referenced for anti-patterns to avoid
|
||||||
|
|
||||||
---
|
---
|
||||||
*Research completed: 2026-03-16*
|
*Research completed: 2026-04-03*
|
||||||
*Ready for roadmap: yes*
|
*Ready for roadmap: yes*
|
||||||
|
|||||||
36
CLAUDE.md
36
CLAUDE.md
@@ -62,6 +62,20 @@ bun run build # Vite build → dist/client/
|
|||||||
- `tests/helpers/db.ts`: `createTestDb()` creates in-memory SQLite via Drizzle migrations and seeds an "Uncategorized" category.
|
- `tests/helpers/db.ts`: `createTestDb()` creates in-memory SQLite via Drizzle migrations and seeds an "Uncategorized" category.
|
||||||
- **E2E**: Playwright (`bun run test:e2e`). Tests in `e2e/` run against a seeded SQLite database with the server in production mode. Seed script: `e2e/seed.ts`.
|
- **E2E**: Playwright (`bun run test:e2e`). Tests in `e2e/` run against a seeded SQLite database with the server in production mode. Seed script: `e2e/seed.ts`.
|
||||||
|
|
||||||
|
## Releasing
|
||||||
|
|
||||||
|
Releases are managed by a Gitea Actions workflow (`.gitea/workflows/release.yml`). **Never create tags or releases manually** — always trigger the pipeline.
|
||||||
|
|
||||||
|
The workflow runs CI (lint, test, build), computes the next version from the latest tag, generates a changelog, creates the tag, builds and pushes a Docker image, and creates a Gitea release.
|
||||||
|
|
||||||
|
Trigger via Gitea API:
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "https://gitea.jeanlucmakiola.de/api/v1/repos/makiolaj/GearBox/actions/workflows/release.yml/dispatches" \
|
||||||
|
-H "Authorization: token <GITEA_TOKEN>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"ref": "Develop", "inputs": {"bump": "patch"}}' # patch | minor | major
|
||||||
|
```
|
||||||
|
|
||||||
## Branching
|
## Branching
|
||||||
|
|
||||||
- **Develop** is the main branch. Keep it clean — don't commit large feature work directly.
|
- **Develop** is the main branch. Keep it clean — don't commit large feature work directly.
|
||||||
@@ -88,10 +102,11 @@ bun run build # Vite build → dist/client/
|
|||||||
- **Programmatic access**: API keys created in Settings > API Keys. Pass via `X-API-Key` header.
|
- **Programmatic access**: API keys created in Settings > API Keys. Pass via `X-API-Key` header.
|
||||||
- **Public read**: All GET endpoints work without auth. POST/PUT/DELETE require auth.
|
- **Public read**: All GET endpoints work without auth. POST/PUT/DELETE require auth.
|
||||||
- **Auth routes**: `/api/auth/login`, `/api/auth/logout`, `/api/auth/setup`, `/api/auth/me`, `/api/auth/password`, `/api/auth/keys`.
|
- **Auth routes**: `/api/auth/login`, `/api/auth/logout`, `/api/auth/setup`, `/api/auth/me`, `/api/auth/password`, `/api/auth/keys`.
|
||||||
|
- **MCP OAuth**: OAuth 2.1 + PKCE for Claude mobile/web. Endpoints at `/oauth/*`. Uses existing GearBox credentials.
|
||||||
|
|
||||||
## MCP Server
|
## MCP Server
|
||||||
|
|
||||||
GearBox includes a built-in MCP server for integration with Claude Code and Claude Desktop. Enabled by default, disable with `GEARBOX_MCP=false`. Authenticated via API key.
|
GearBox includes a built-in MCP server for integration with Claude Code and Claude Desktop. Enabled by default, disable with `GEARBOX_MCP=false`. Authenticated via API key or OAuth 2.1 Bearer token.
|
||||||
|
|
||||||
### Tools (19 total)
|
### Tools (19 total)
|
||||||
|
|
||||||
@@ -154,3 +169,22 @@ GearBox includes a built-in MCP server for integration with Claude Code and Clau
|
|||||||
```
|
```
|
||||||
|
|
||||||
Generate an API key from Settings > API Keys after logging in.
|
Generate an API key from Settings > API Keys after logging in.
|
||||||
|
|
||||||
|
### OAuth Authentication (Claude Mobile / claude.ai)
|
||||||
|
|
||||||
|
GearBox supports OAuth 2.1 Authorization Code + PKCE for MCP connections from Claude mobile app and claude.ai. The OAuth flow is automatic — Claude handles discovery and token exchange.
|
||||||
|
|
||||||
|
**Required environment variable for production:**
|
||||||
|
```bash
|
||||||
|
GEARBOX_URL=https://your-gearbox-domain.com # Used as OAuth issuer URL
|
||||||
|
```
|
||||||
|
|
||||||
|
**OAuth endpoints:**
|
||||||
|
- `GET /.well-known/oauth-authorization-server` — Discovery metadata
|
||||||
|
- `POST /oauth/register` — Dynamic Client Registration
|
||||||
|
- `GET/POST /oauth/authorize` — Authorization with login form
|
||||||
|
- `POST /oauth/token` — Token exchange and refresh
|
||||||
|
|
||||||
|
**Both auth methods work simultaneously:**
|
||||||
|
- **API key** (`X-API-Key` header) — Claude Code, scripts, programmatic access
|
||||||
|
- **OAuth Bearer token** (`Authorization: Bearer` header) — Claude mobile, claude.ai
|
||||||
1372
docs/superpowers/plans/2026-04-04-mcp-oauth.md
Normal file
1372
docs/superpowers/plans/2026-04-04-mcp-oauth.md
Normal file
File diff suppressed because it is too large
Load Diff
35
docs/superpowers/specs/2026-04-03-user-menu-design.md
Normal file
35
docs/superpowers/specs/2026-04-03-user-menu-design.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# User Menu Design
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Replace the plain "Sign out" button in the header with a user icon that opens a dropdown menu containing Settings and Sign out options. This provides a way to navigate to the Settings page from the header.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### UserMenu (`src/client/components/UserMenu.tsx`)
|
||||||
|
|
||||||
|
New component rendered by `TotalsBar` when authenticated.
|
||||||
|
|
||||||
|
**Trigger:** Circular `CircleUser` icon button (Lucide). Styled consistently with surrounding header elements.
|
||||||
|
|
||||||
|
**Dropdown:** Absolutely-positioned popover anchored to the right edge, appearing below the icon:
|
||||||
|
|
||||||
|
1. **Settings** — `Settings` (gear) icon + "Settings" label, `<Link to="/settings">`
|
||||||
|
2. **Divider** — thin horizontal line
|
||||||
|
3. **Sign out** — `LogOut` icon + "Sign out" label, calls `logout.mutate()` from `useLogout()`
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Click icon toggles open/close
|
||||||
|
- Click outside closes (via `useEffect` with document click listener)
|
||||||
|
- Clicking a menu item closes the dropdown
|
||||||
|
- Dropdown anchored right so it doesn't overflow viewport
|
||||||
|
|
||||||
|
### TotalsBar Changes (`src/client/components/TotalsBar.tsx`)
|
||||||
|
|
||||||
|
- When `isAuthenticated`: render `<UserMenu />` in place of the current "Sign out" button
|
||||||
|
- When not authenticated: keep the existing "Sign in" link unchanged
|
||||||
|
- Remove the `useLogout` hook usage from TotalsBar (moved into UserMenu)
|
||||||
|
|
||||||
|
## No Backend Changes
|
||||||
|
|
||||||
|
The existing `/api/auth/me` endpoint and `useAuth` hook are sufficient. No username display needed — using a generic user icon.
|
||||||
182
docs/superpowers/specs/2026-04-04-mcp-oauth-design.md
Normal file
182
docs/superpowers/specs/2026-04-04-mcp-oauth-design.md
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
# MCP OAuth 2.1 Server Design
|
||||||
|
|
||||||
|
**Date:** 2026-04-04
|
||||||
|
**Goal:** Add OAuth 2.1 Authorization Code + PKCE support to the MCP server so it works with Claude mobile app and claude.ai remote MCP connectors.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The GearBox MCP server currently authenticates via `X-API-Key` header. This works for Claude Code (CLI) and scripts, but Claude mobile and claude.ai require OAuth 2.1 for remote MCP server connections. The MCP spec (2025-03-26) defines exactly which OAuth endpoints a server must expose.
|
||||||
|
|
||||||
|
This is built against the current single-user auth system (username/password in SQLite). It will be replaced when the platform migrates to an external auth provider in v2.0.
|
||||||
|
|
||||||
|
## OAuth Flow
|
||||||
|
|
||||||
|
The MCP authorization spec requires OAuth 2.1 Authorization Code + PKCE:
|
||||||
|
|
||||||
|
1. Client calls `POST /mcp` -> gets `401 Unauthorized` with `WWW-Authenticate` header
|
||||||
|
2. Client fetches `GET /.well-known/oauth-authorization-server` for endpoint discovery
|
||||||
|
3. Client calls `POST /oauth/register` (Dynamic Client Registration, RFC 7591) to get a `client_id`
|
||||||
|
4. Client opens browser to `GET /oauth/authorize?response_type=code&client_id=...&code_challenge=...&code_challenge_method=S256&redirect_uri=...`
|
||||||
|
5. User sees a login form, enters their GearBox password
|
||||||
|
6. Server validates credentials, generates auth code, redirects to `redirect_uri?code=xyz`
|
||||||
|
7. Client calls `POST /oauth/token` with `grant_type=authorization_code&code=xyz&code_verifier=...`
|
||||||
|
8. Server validates PKCE, returns `{ access_token, refresh_token, token_type, expires_in }`
|
||||||
|
9. All subsequent MCP requests use `Authorization: Bearer <access_token>`
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
Three new tables in `src/db/schema.ts`:
|
||||||
|
|
||||||
|
### `oauthClients`
|
||||||
|
Stores dynamically registered OAuth clients (one per Claude instance).
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| id | integer PK | Auto-increment |
|
||||||
|
| clientId | text, unique | UUID, generated on registration |
|
||||||
|
| clientName | text | From registration request, optional |
|
||||||
|
| redirectUris | text | JSON array of allowed redirect URIs |
|
||||||
|
| createdAt | integer (timestamp) | |
|
||||||
|
|
||||||
|
### `oauthCodes`
|
||||||
|
Short-lived authorization codes (10 minute TTL).
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| id | integer PK | Auto-increment |
|
||||||
|
| code | text, unique | Cryptographically random |
|
||||||
|
| clientId | text | FK to oauthClients.clientId |
|
||||||
|
| codeChallenge | text | PKCE S256 challenge |
|
||||||
|
| codeChallengeMethod | text | Always "S256" |
|
||||||
|
| redirectUri | text | Must match on token exchange |
|
||||||
|
| expiresAt | integer (timestamp) | 10 minutes from creation |
|
||||||
|
| used | integer | 0 or 1, prevents replay |
|
||||||
|
|
||||||
|
### `oauthTokens`
|
||||||
|
Access and refresh tokens.
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| id | integer PK | Auto-increment |
|
||||||
|
| accessTokenHash | text, unique | SHA-256 hash of token |
|
||||||
|
| refreshTokenHash | text, unique | SHA-256 hash of token |
|
||||||
|
| clientId | text | FK to oauthClients.clientId |
|
||||||
|
| expiresAt | integer (timestamp) | Access token expiry (1 hour) |
|
||||||
|
| createdAt | integer (timestamp) | |
|
||||||
|
|
||||||
|
No `userId` column -- single-user app, only one user exists. Tokens implicitly belong to them.
|
||||||
|
|
||||||
|
## New Files
|
||||||
|
|
||||||
|
### `src/server/services/oauth.service.ts`
|
||||||
|
Pure business logic, no HTTP awareness. Functions:
|
||||||
|
|
||||||
|
- `registerClient(name?, redirectUris)` -> `{ clientId }` - creates client record
|
||||||
|
- `getClient(clientId)` -> client record or null
|
||||||
|
- `createAuthorizationCode(clientId, codeChallenge, codeChallengeMethod, redirectUri)` -> `{ code }` - generates code, stores in DB
|
||||||
|
- `exchangeCode(code, codeVerifier, clientId, redirectUri)` -> `{ accessToken, refreshToken, expiresIn }` - validates PKCE, marks code used, creates tokens
|
||||||
|
- `refreshAccessToken(refreshToken, clientId)` -> `{ accessToken, refreshToken, expiresIn }` - rotates refresh token
|
||||||
|
- `verifyAccessToken(token)` -> boolean - checks hash against DB, checks expiry
|
||||||
|
- `cleanExpiredTokens()` -> void - housekeeping, called opportunistically
|
||||||
|
|
||||||
|
PKCE validation: SHA-256 hash the `code_verifier`, base64url-encode it, compare to stored `code_challenge`.
|
||||||
|
|
||||||
|
Token generation: `crypto.randomBytes(32).toString('hex')` for both access and refresh tokens. Stored as SHA-256 hashes.
|
||||||
|
|
||||||
|
### `src/server/routes/oauth.ts`
|
||||||
|
Hono routes mounted at `/oauth` in `src/server/index.ts`:
|
||||||
|
|
||||||
|
**`GET /.well-known/oauth-authorization-server`** (mounted at app root, not under `/oauth`)
|
||||||
|
Returns RFC 8414 metadata. The issuer URL is derived from the `GEARBOX_URL` environment variable (e.g., `https://gearbox.example.com`), falling back to the request's `Origin` or `Host` header for local development:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"issuer": "<issuer_url>",
|
||||||
|
"authorization_endpoint": "<issuer_url>/oauth/authorize",
|
||||||
|
"token_endpoint": "<issuer_url>/oauth/token",
|
||||||
|
"registration_endpoint": "<issuer_url>/oauth/register",
|
||||||
|
"response_types_supported": ["code"],
|
||||||
|
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||||
|
"code_challenge_methods_supported": ["S256"],
|
||||||
|
"token_endpoint_auth_methods_supported": ["none"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`POST /oauth/register`** (Dynamic Client Registration, RFC 7591)
|
||||||
|
Request: `{ client_name?, redirect_uris: string[] }`
|
||||||
|
Response: `{ client_id, client_name, redirect_uris }`
|
||||||
|
|
||||||
|
**`GET /oauth/authorize`**
|
||||||
|
Query params: `response_type=code`, `client_id`, `redirect_uri`, `code_challenge`, `code_challenge_method=S256`, `state`
|
||||||
|
Validates client_id and redirect_uri, then serves a simple HTML login form.
|
||||||
|
|
||||||
|
**`POST /oauth/authorize`**
|
||||||
|
Form body: `username`, `password` (plus the OAuth params from the query string, passed as hidden fields)
|
||||||
|
Validates credentials via existing `verifyPassword()`. On success, generates auth code and redirects: `302 redirect_uri?code=xyz&state=...`
|
||||||
|
On failure, re-renders login form with error message.
|
||||||
|
|
||||||
|
**`POST /oauth/token`**
|
||||||
|
Handles two grant types:
|
||||||
|
- `authorization_code`: validates code, PKCE verifier, redirect_uri, client_id. Returns tokens.
|
||||||
|
- `refresh_token`: validates refresh token, rotates it. Returns new tokens.
|
||||||
|
|
||||||
|
Response: `{ access_token, refresh_token, token_type: "Bearer", expires_in: 3600 }`
|
||||||
|
|
||||||
|
### Login Form
|
||||||
|
|
||||||
|
The `/oauth/authorize` GET endpoint serves a minimal HTML login page. Self-contained (inline styles), matches GearBox's clean aesthetic. Shows:
|
||||||
|
- GearBox logo/name
|
||||||
|
- "Authorize [client_name] to access your GearBox data"
|
||||||
|
- Username + password fields
|
||||||
|
- "Authorize" button
|
||||||
|
- Error message area
|
||||||
|
|
||||||
|
This is server-rendered HTML (not React) since it's a standalone page in the OAuth redirect flow.
|
||||||
|
|
||||||
|
## Modified Files
|
||||||
|
|
||||||
|
### `src/server/mcp/index.ts`
|
||||||
|
Update the auth middleware to accept both auth methods:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Check Authorization: Bearer <token> -> verify via oauth.service.verifyAccessToken()
|
||||||
|
2. Check X-API-Key header -> existing verifyApiKey() flow
|
||||||
|
3. No auth found -> return 401 with WWW-Authenticate: Bearer header
|
||||||
|
```
|
||||||
|
|
||||||
|
The `WWW-Authenticate` header is what triggers the OAuth flow in MCP clients.
|
||||||
|
|
||||||
|
### `src/server/index.ts`
|
||||||
|
- Mount `/.well-known/oauth-authorization-server` at app root
|
||||||
|
- Mount `/oauth` routes
|
||||||
|
|
||||||
|
### `src/db/schema.ts`
|
||||||
|
Add the 3 new table definitions.
|
||||||
|
|
||||||
|
## Token Lifecycle
|
||||||
|
|
||||||
|
- **Access tokens:** 1 hour expiry. Validated on every MCP request by hashing and checking DB.
|
||||||
|
- **Refresh tokens:** 30 day expiry. Rotated on each use (old token invalidated, new one issued).
|
||||||
|
- **Auth codes:** 10 minute expiry. Single-use (marked `used=1` after exchange).
|
||||||
|
- **Cleanup:** Expired tokens/codes cleaned up opportunistically during token operations.
|
||||||
|
|
||||||
|
## What This Does NOT Include
|
||||||
|
|
||||||
|
- **Scopes/permissions:** Single user with full access. No scope restrictions.
|
||||||
|
- **Consent screen:** Login = consent. There's only one user authorizing themselves.
|
||||||
|
- **Token revocation endpoint:** Not required by MCP spec. Tokens expire naturally.
|
||||||
|
- **CORS changes:** OAuth uses browser redirects, not CORS.
|
||||||
|
- **Changes to existing API key auth:** Fully preserved, works alongside OAuth.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Unit tests for `oauth.service.ts`: PKCE validation, code exchange, token refresh, expiry
|
||||||
|
- Route-level tests for OAuth endpoints: registration, authorize flow, token exchange, error cases
|
||||||
|
- Integration: verify Bearer token auth works on MCP endpoint alongside API key auth
|
||||||
|
|
||||||
|
## Migration Path
|
||||||
|
|
||||||
|
When v2.0 replaces auth with an external OIDC provider:
|
||||||
|
- The OAuth tables get dropped (external provider handles tokens)
|
||||||
|
- The `/oauth/*` routes get replaced or proxied to the external provider
|
||||||
|
- Bearer token validation on `/mcp` switches to JWT verification
|
||||||
|
- API key auth stays (it's in the local DB regardless)
|
||||||
33
drizzle/0009_happy_mockingbird.sql
Normal file
33
drizzle/0009_happy_mockingbird.sql
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
CREATE TABLE `oauth_clients` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`client_id` text NOT NULL,
|
||||||
|
`client_name` text,
|
||||||
|
`redirect_uris` text NOT NULL,
|
||||||
|
`created_at` integer NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `oauth_clients_client_id_unique` ON `oauth_clients` (`client_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `oauth_codes` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`code` text NOT NULL,
|
||||||
|
`client_id` text NOT NULL,
|
||||||
|
`code_challenge` text NOT NULL,
|
||||||
|
`code_challenge_method` text DEFAULT 'S256' NOT NULL,
|
||||||
|
`redirect_uri` text NOT NULL,
|
||||||
|
`expires_at` integer NOT NULL,
|
||||||
|
`used` integer DEFAULT 0 NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `oauth_codes_code_unique` ON `oauth_codes` (`code`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `oauth_tokens` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`access_token_hash` text NOT NULL,
|
||||||
|
`refresh_token_hash` text NOT NULL,
|
||||||
|
`client_id` text NOT NULL,
|
||||||
|
`expires_at` integer NOT NULL,
|
||||||
|
`refresh_expires_at` integer NOT NULL,
|
||||||
|
`created_at` integer NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `oauth_tokens_access_token_hash_unique` ON `oauth_tokens` (`access_token_hash`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `oauth_tokens_refresh_token_hash_unique` ON `oauth_tokens` (`refresh_token_hash`);
|
||||||
866
drizzle/meta/0009_snapshot.json
Normal file
866
drizzle/meta/0009_snapshot.json
Normal file
@@ -0,0 +1,866 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "ec8780d0-5541-41b1-974d-399f30e83364",
|
||||||
|
"prevId": "ede9f482-7af0-42bc-9672-43f5fba289d0",
|
||||||
|
"tables": {
|
||||||
|
"api_keys": {
|
||||||
|
"name": "api_keys",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"key_hash": {
|
||||||
|
"name": "key_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"key_prefix": {
|
||||||
|
"name": "key_prefix",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"name": "categories",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"icon": {
|
||||||
|
"name": "icon",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'package'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"categories_name_unique": {
|
||||||
|
"name": "categories_name_unique",
|
||||||
|
"columns": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"name": "items",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"weight_grams": {
|
||||||
|
"name": "weight_grams",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"price_cents": {
|
||||||
|
"name": "price_cents",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"category_id": {
|
||||||
|
"name": "category_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"name": "notes",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"product_url": {
|
||||||
|
"name": "product_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"image_filename": {
|
||||||
|
"name": "image_filename",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"image_source_url": {
|
||||||
|
"name": "image_source_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"quantity": {
|
||||||
|
"name": "quantity",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"items_category_id_categories_id_fk": {
|
||||||
|
"name": "items_category_id_categories_id_fk",
|
||||||
|
"tableFrom": "items",
|
||||||
|
"tableTo": "categories",
|
||||||
|
"columnsFrom": [
|
||||||
|
"category_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"oauth_clients": {
|
||||||
|
"name": "oauth_clients",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"client_id": {
|
||||||
|
"name": "client_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"client_name": {
|
||||||
|
"name": "client_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"redirect_uris": {
|
||||||
|
"name": "redirect_uris",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"oauth_clients_client_id_unique": {
|
||||||
|
"name": "oauth_clients_client_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"client_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"oauth_codes": {
|
||||||
|
"name": "oauth_codes",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"code": {
|
||||||
|
"name": "code",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"client_id": {
|
||||||
|
"name": "client_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"code_challenge": {
|
||||||
|
"name": "code_challenge",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"code_challenge_method": {
|
||||||
|
"name": "code_challenge_method",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'S256'"
|
||||||
|
},
|
||||||
|
"redirect_uri": {
|
||||||
|
"name": "redirect_uri",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"used": {
|
||||||
|
"name": "used",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"oauth_codes_code_unique": {
|
||||||
|
"name": "oauth_codes_code_unique",
|
||||||
|
"columns": [
|
||||||
|
"code"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"oauth_tokens": {
|
||||||
|
"name": "oauth_tokens",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"access_token_hash": {
|
||||||
|
"name": "access_token_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"refresh_token_hash": {
|
||||||
|
"name": "refresh_token_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"client_id": {
|
||||||
|
"name": "client_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"refresh_expires_at": {
|
||||||
|
"name": "refresh_expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"oauth_tokens_access_token_hash_unique": {
|
||||||
|
"name": "oauth_tokens_access_token_hash_unique",
|
||||||
|
"columns": [
|
||||||
|
"access_token_hash"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"oauth_tokens_refresh_token_hash_unique": {
|
||||||
|
"name": "oauth_tokens_refresh_token_hash_unique",
|
||||||
|
"columns": [
|
||||||
|
"refresh_token_hash"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sessions_user_id_users_id_fk": {
|
||||||
|
"name": "sessions_user_id_users_id_fk",
|
||||||
|
"tableFrom": "sessions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"name": "settings",
|
||||||
|
"columns": {
|
||||||
|
"key": {
|
||||||
|
"name": "key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"setup_items": {
|
||||||
|
"name": "setup_items",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"setup_id": {
|
||||||
|
"name": "setup_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"item_id": {
|
||||||
|
"name": "item_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"classification": {
|
||||||
|
"name": "classification",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'base'"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"setup_items_setup_id_setups_id_fk": {
|
||||||
|
"name": "setup_items_setup_id_setups_id_fk",
|
||||||
|
"tableFrom": "setup_items",
|
||||||
|
"tableTo": "setups",
|
||||||
|
"columnsFrom": [
|
||||||
|
"setup_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"setup_items_item_id_items_id_fk": {
|
||||||
|
"name": "setup_items_item_id_items_id_fk",
|
||||||
|
"tableFrom": "setup_items",
|
||||||
|
"tableTo": "items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"item_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"setups": {
|
||||||
|
"name": "setups",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"thread_candidates": {
|
||||||
|
"name": "thread_candidates",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"thread_id": {
|
||||||
|
"name": "thread_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"weight_grams": {
|
||||||
|
"name": "weight_grams",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"price_cents": {
|
||||||
|
"name": "price_cents",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"category_id": {
|
||||||
|
"name": "category_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"name": "notes",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"product_url": {
|
||||||
|
"name": "product_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"image_filename": {
|
||||||
|
"name": "image_filename",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"image_source_url": {
|
||||||
|
"name": "image_source_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'researching'"
|
||||||
|
},
|
||||||
|
"pros": {
|
||||||
|
"name": "pros",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"cons": {
|
||||||
|
"name": "cons",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"sort_order": {
|
||||||
|
"name": "sort_order",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"thread_candidates_thread_id_threads_id_fk": {
|
||||||
|
"name": "thread_candidates_thread_id_threads_id_fk",
|
||||||
|
"tableFrom": "thread_candidates",
|
||||||
|
"tableTo": "threads",
|
||||||
|
"columnsFrom": [
|
||||||
|
"thread_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"thread_candidates_category_id_categories_id_fk": {
|
||||||
|
"name": "thread_candidates_category_id_categories_id_fk",
|
||||||
|
"tableFrom": "thread_candidates",
|
||||||
|
"tableTo": "categories",
|
||||||
|
"columnsFrom": [
|
||||||
|
"category_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"threads": {
|
||||||
|
"name": "threads",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'active'"
|
||||||
|
},
|
||||||
|
"resolved_candidate_id": {
|
||||||
|
"name": "resolved_candidate_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"category_id": {
|
||||||
|
"name": "category_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"threads_category_id_categories_id_fk": {
|
||||||
|
"name": "threads_category_id_categories_id_fk",
|
||||||
|
"tableFrom": "threads",
|
||||||
|
"tableTo": "categories",
|
||||||
|
"columnsFrom": [
|
||||||
|
"category_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_username_unique": {
|
||||||
|
"name": "users_username_unique",
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,6 +64,13 @@
|
|||||||
"when": 1775232090363,
|
"when": 1775232090363,
|
||||||
"tag": "0008_loving_colossus",
|
"tag": "0008_loving_colossus",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 9,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1775287060443,
|
||||||
|
"tag": "0009_happy_mockingbird",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
BIN
e2e/test.db-shm
Normal file
BIN
e2e/test.db-shm
Normal file
Binary file not shown.
BIN
e2e/test.db-wal
Normal file
BIN
e2e/test.db-wal
Normal file
Binary file not shown.
@@ -1,10 +1,11 @@
|
|||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { useAuth, useLogout } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
import { useFormatters } from "../hooks/useFormatters";
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
import { useUpdateSetting } from "../hooks/useSettings";
|
import { useUpdateSetting } from "../hooks/useSettings";
|
||||||
import { useTotals } from "../hooks/useTotals";
|
import { useTotals } from "../hooks/useTotals";
|
||||||
import type { WeightUnit } from "../lib/formatters";
|
import type { WeightUnit } from "../lib/formatters";
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
|
import { UserMenu } from "./UserMenu";
|
||||||
|
|
||||||
const UNITS: WeightUnit[] = ["g", "oz", "lb", "kg"];
|
const UNITS: WeightUnit[] = ["g", "oz", "lb", "kg"];
|
||||||
|
|
||||||
@@ -21,7 +22,6 @@ export function TotalsBar({
|
|||||||
}: TotalsBarProps) {
|
}: TotalsBarProps) {
|
||||||
const { data } = useTotals();
|
const { data } = useTotals();
|
||||||
const { data: auth } = useAuth();
|
const { data: auth } = useAuth();
|
||||||
const logout = useLogout();
|
|
||||||
const isAuthenticated = !!auth?.user;
|
const isAuthenticated = !!auth?.user;
|
||||||
const { weight, price, unit } = useFormatters();
|
const { weight, price, unit } = useFormatters();
|
||||||
const updateSetting = useUpdateSetting();
|
const updateSetting = useUpdateSetting();
|
||||||
@@ -104,15 +104,8 @@ export function TotalsBar({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2 ml-auto">
|
|
||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<button
|
<UserMenu />
|
||||||
type="button"
|
|
||||||
onClick={() => logout.mutate()}
|
|
||||||
className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
|
|
||||||
>
|
|
||||||
Sign out
|
|
||||||
</button>
|
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
to="/login"
|
to="/login"
|
||||||
@@ -125,6 +118,5 @@ export function TotalsBar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
57
src/client/components/UserMenu.tsx
Normal file
57
src/client/components/UserMenu.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useLogout } from "../hooks/useAuth";
|
||||||
|
import { LucideIcon } from "../lib/iconData";
|
||||||
|
|
||||||
|
export function UserMenu() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const logout = useLogout();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClick);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClick);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={menuRef} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((prev) => !prev)}
|
||||||
|
className="flex items-center justify-center w-8 h-8 rounded-full text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<LucideIcon name="circle-user" size={22} />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 mt-1 w-40 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50">
|
||||||
|
<Link
|
||||||
|
to="/settings"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<LucideIcon name="settings" size={16} className="text-gray-400" />
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
<div className="border-t border-gray-100 my-1" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
logout.mutate();
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<LucideIcon name="log-out" size={16} className="text-gray-400" />
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -126,3 +126,38 @@ export const apiKeys = sqliteTable("api_keys", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.$defaultFn(() => new Date()),
|
.$defaultFn(() => new Date()),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const oauthClients = sqliteTable("oauth_clients", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
clientId: text("client_id").notNull().unique(),
|
||||||
|
clientName: text("client_name"),
|
||||||
|
redirectUris: text("redirect_uris").notNull(), // JSON array
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp" })
|
||||||
|
.notNull()
|
||||||
|
.$defaultFn(() => new Date()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const oauthCodes = sqliteTable("oauth_codes", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
code: text("code").notNull().unique(),
|
||||||
|
clientId: text("client_id").notNull(),
|
||||||
|
codeChallenge: text("code_challenge").notNull(),
|
||||||
|
codeChallengeMethod: text("code_challenge_method").notNull().default("S256"),
|
||||||
|
redirectUri: text("redirect_uri").notNull(),
|
||||||
|
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
||||||
|
used: integer("used").notNull().default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const oauthTokens = sqliteTable("oauth_tokens", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
accessTokenHash: text("access_token_hash").notNull().unique(),
|
||||||
|
refreshTokenHash: text("refresh_token_hash").notNull().unique(),
|
||||||
|
clientId: text("client_id").notNull(),
|
||||||
|
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), // access token expiry
|
||||||
|
refreshExpiresAt: integer("refresh_expires_at", {
|
||||||
|
mode: "timestamp",
|
||||||
|
}).notNull(), // refresh token expiry
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp" })
|
||||||
|
.notNull()
|
||||||
|
.$defaultFn(() => new Date()),
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { serveStatic } from "hono/bun";
|
import { serveStatic } from "hono/bun";
|
||||||
|
import { cors } from "hono/cors";
|
||||||
import { db as prodDb } from "../db/index.ts";
|
import { db as prodDb } from "../db/index.ts";
|
||||||
import { seedDefaults } from "../db/seed.ts";
|
import { seedDefaults } from "../db/seed.ts";
|
||||||
import { mcpRoutes } from "./mcp/index.ts";
|
import { mcpRoutes } from "./mcp/index.ts";
|
||||||
@@ -8,6 +9,7 @@ import { authRoutes } from "./routes/auth.ts";
|
|||||||
import { categoryRoutes } from "./routes/categories.ts";
|
import { categoryRoutes } from "./routes/categories.ts";
|
||||||
import { imageRoutes } from "./routes/images.ts";
|
import { imageRoutes } from "./routes/images.ts";
|
||||||
import { itemRoutes } from "./routes/items.ts";
|
import { itemRoutes } from "./routes/items.ts";
|
||||||
|
import { oauthRoutes, wellKnownRoute } from "./routes/oauth.ts";
|
||||||
import { settingsRoutes } from "./routes/settings.ts";
|
import { settingsRoutes } from "./routes/settings.ts";
|
||||||
import { setupRoutes } from "./routes/setups.ts";
|
import { setupRoutes } from "./routes/setups.ts";
|
||||||
import { threadRoutes } from "./routes/threads.ts";
|
import { threadRoutes } from "./routes/threads.ts";
|
||||||
@@ -33,6 +35,19 @@ app.get("/api/health", (c) => {
|
|||||||
return c.json({ status: "ok" });
|
return c.json({ status: "ok" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// CORS for OAuth and MCP endpoints (required for claude.ai browser-based flows)
|
||||||
|
app.use("/.well-known/*", cors());
|
||||||
|
app.use("/oauth/*", cors());
|
||||||
|
app.use("/mcp/*", cors());
|
||||||
|
|
||||||
|
// OAuth routes (must be before /api/* middleware)
|
||||||
|
app.use("/oauth/*", async (c, next) => {
|
||||||
|
c.set("db", prodDb);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
app.route("/.well-known", wellKnownRoute);
|
||||||
|
app.route("/oauth", oauthRoutes);
|
||||||
|
|
||||||
// Inject production database into request context
|
// Inject production database into request context
|
||||||
app.use("/api/*", async (c, next) => {
|
app.use("/api/*", async (c, next) => {
|
||||||
c.set("db", prodDb);
|
c.set("db", prodDb);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { db as prodDb } from "../../db/index.ts";
|
import { db as prodDb } from "../../db/index.ts";
|
||||||
import { getUserCount, verifyApiKey } from "../services/auth.service.ts";
|
import { getUserCount, verifyApiKey } from "../services/auth.service.ts";
|
||||||
|
import { verifyAccessToken } from "../services/oauth.service.ts";
|
||||||
import { getCollectionSummary } from "./resources/collection.ts";
|
import { getCollectionSummary } from "./resources/collection.ts";
|
||||||
import {
|
import {
|
||||||
categoryToolDefinitions,
|
categoryToolDefinitions,
|
||||||
@@ -88,20 +89,37 @@ export const mcpRoutes = new Hono();
|
|||||||
// Auth middleware for all MCP requests
|
// Auth middleware for all MCP requests
|
||||||
mcpRoutes.use("/*", async (c, next) => {
|
mcpRoutes.use("/*", async (c, next) => {
|
||||||
const db = c.get("db") ?? prodDb;
|
const db = c.get("db") ?? prodDb;
|
||||||
const apiKey = c.req.header("X-API-Key");
|
|
||||||
|
|
||||||
// Require API key when auth is configured (users exist)
|
// Skip auth if no users exist
|
||||||
if (getUserCount(db) > 0) {
|
if (getUserCount(db) <= 0) {
|
||||||
if (!apiKey) {
|
return next();
|
||||||
return c.json({ error: "API key required" }, 401);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try Bearer token first (OAuth)
|
||||||
|
const authHeader = c.req.header("Authorization");
|
||||||
|
if (authHeader?.startsWith("Bearer ")) {
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
if (verifyAccessToken(db, token)) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
return c.json({ error: "invalid_token" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try API key (existing flow)
|
||||||
|
const apiKey = c.req.header("X-API-Key");
|
||||||
|
if (apiKey) {
|
||||||
const valid = await verifyApiKey(db, apiKey);
|
const valid = await verifyApiKey(db, apiKey);
|
||||||
if (!valid) {
|
if (valid) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
return c.json({ error: "Invalid API key" }, 401);
|
return c.json({ error: "Invalid API key" }, 401);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return next();
|
// No auth provided — return 401 with WWW-Authenticate to trigger OAuth flow
|
||||||
|
return c.text("Unauthorized", 401, {
|
||||||
|
"WWW-Authenticate":
|
||||||
|
'Bearer resource_metadata="/.well-known/oauth-authorization-server"',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
mcpRoutes.post("/", async (c) => {
|
mcpRoutes.post("/", async (c) => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { z } from "zod";
|
||||||
import type { db as prodDb } from "../../../db/index.ts";
|
import type { db as prodDb } from "../../../db/index.ts";
|
||||||
import {
|
import {
|
||||||
createCategory,
|
createCategory,
|
||||||
@@ -24,24 +25,14 @@ export const categoryToolDefinitions = [
|
|||||||
{
|
{
|
||||||
name: "list_categories",
|
name: "list_categories",
|
||||||
description: "List all gear categories.",
|
description: "List all gear categories.",
|
||||||
inputSchema: {
|
inputSchema: {},
|
||||||
type: "object" as const,
|
|
||||||
properties: {},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "create_category",
|
name: "create_category",
|
||||||
description: "Create a new gear category.",
|
description: "Create a new gear category.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
name: z.string().describe("Category name"),
|
||||||
properties: {
|
icon: z.string().optional().describe("Icon name (defaults to 'package')"),
|
||||||
name: { type: "string", description: "Category name" },
|
|
||||||
icon: {
|
|
||||||
type: "string",
|
|
||||||
description: "Icon name (defaults to 'package')",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["name"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { z } from "zod";
|
||||||
import { fetchImageFromUrl } from "../../services/image.service.ts";
|
import { fetchImageFromUrl } from "../../services/image.service.ts";
|
||||||
|
|
||||||
interface ToolResult {
|
interface ToolResult {
|
||||||
@@ -20,14 +21,9 @@ export const imageToolDefinitions = [
|
|||||||
description:
|
description:
|
||||||
"Fetch an image from a URL and save it locally. Returns the filename to use with create_item or add_candidate.",
|
"Fetch an image from a URL and save it locally. Returns the filename to use with create_item or add_candidate.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
url: z
|
||||||
properties: {
|
.string()
|
||||||
url: {
|
.describe("URL of the image to fetch (jpeg, png, or webp)"),
|
||||||
type: "string",
|
|
||||||
description: "URL of the image to fetch (jpeg, png, or webp)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["url"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { z } from "zod";
|
||||||
import type { db as prodDb } from "../../../db/index.ts";
|
import type { db as prodDb } from "../../../db/index.ts";
|
||||||
import {
|
import {
|
||||||
createItem,
|
createItem,
|
||||||
@@ -29,24 +30,14 @@ export const itemToolDefinitions = [
|
|||||||
description:
|
description:
|
||||||
"List all items in the gear collection, optionally filtered by category.",
|
"List all items in the gear collection, optionally filtered by category.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
categoryId: z.number().optional().describe("Filter items by category ID"),
|
||||||
properties: {
|
|
||||||
categoryId: {
|
|
||||||
type: "number",
|
|
||||||
description: "Filter items by category ID",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "get_item",
|
name: "get_item",
|
||||||
description: "Get a single item by its ID, including all details.",
|
description: "Get a single item by its ID, including all details.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
id: z.number().describe("The item ID"),
|
||||||
properties: {
|
|
||||||
id: { type: "number", description: "The item ID" },
|
|
||||||
},
|
|
||||||
required: ["id"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -54,60 +45,48 @@ export const itemToolDefinitions = [
|
|||||||
description:
|
description:
|
||||||
"Add a new item to the gear collection. Use this for items you've already decided on. For items you're still researching, use create_thread instead.",
|
"Add a new item to the gear collection. Use this for items you've already decided on. For items you're still researching, use create_thread instead.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
name: z.string().describe("Item name"),
|
||||||
properties: {
|
categoryId: z.number().describe("Category ID"),
|
||||||
name: { type: "string", description: "Item name" },
|
weightGrams: z.number().optional().describe("Weight in grams"),
|
||||||
categoryId: { type: "number", description: "Category ID" },
|
priceCents: z.number().optional().describe("Price in cents"),
|
||||||
weightGrams: { type: "number", description: "Weight in grams" },
|
notes: z.string().optional().describe("Notes about the item"),
|
||||||
priceCents: { type: "number", description: "Price in cents" },
|
productUrl: z.string().optional().describe("URL to the product page"),
|
||||||
notes: { type: "string", description: "Notes about the item" },
|
imageFilename: z
|
||||||
productUrl: { type: "string", description: "URL to the product page" },
|
.string()
|
||||||
imageFilename: {
|
.optional()
|
||||||
type: "string",
|
.describe("Filename of an uploaded image"),
|
||||||
description: "Filename of an uploaded image",
|
imageSourceUrl: z
|
||||||
},
|
.string()
|
||||||
imageSourceUrl: {
|
.optional()
|
||||||
type: "string",
|
.describe("Original URL the image was fetched from"),
|
||||||
description: "Original URL the image was fetched from",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["name", "categoryId"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "update_item",
|
name: "update_item",
|
||||||
description: "Update an existing item's fields.",
|
description: "Update an existing item's fields.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
id: z.number().describe("The item ID to update"),
|
||||||
properties: {
|
name: z.string().optional().describe("Item name"),
|
||||||
id: { type: "number", description: "The item ID to update" },
|
categoryId: z.number().optional().describe("Category ID"),
|
||||||
name: { type: "string", description: "Item name" },
|
weightGrams: z.number().optional().describe("Weight in grams"),
|
||||||
categoryId: { type: "number", description: "Category ID" },
|
priceCents: z.number().optional().describe("Price in cents"),
|
||||||
weightGrams: { type: "number", description: "Weight in grams" },
|
notes: z.string().optional().describe("Notes about the item"),
|
||||||
priceCents: { type: "number", description: "Price in cents" },
|
productUrl: z.string().optional().describe("URL to the product page"),
|
||||||
notes: { type: "string", description: "Notes about the item" },
|
imageFilename: z
|
||||||
productUrl: { type: "string", description: "URL to the product page" },
|
.string()
|
||||||
imageFilename: {
|
.optional()
|
||||||
type: "string",
|
.describe("Filename of an uploaded image"),
|
||||||
description: "Filename of an uploaded image",
|
imageSourceUrl: z
|
||||||
},
|
.string()
|
||||||
imageSourceUrl: {
|
.optional()
|
||||||
type: "string",
|
.describe("Original URL the image was fetched from"),
|
||||||
description: "Original URL the image was fetched from",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["id"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "delete_item",
|
name: "delete_item",
|
||||||
description: "Delete an item from the gear collection by ID.",
|
description: "Delete an item from the gear collection by ID.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
id: z.number().describe("The item ID to delete"),
|
||||||
properties: {
|
|
||||||
id: { type: "number", description: "The item ID to delete" },
|
|
||||||
},
|
|
||||||
required: ["id"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { z } from "zod";
|
||||||
import type { db as prodDb } from "../../../db/index.ts";
|
import type { db as prodDb } from "../../../db/index.ts";
|
||||||
import {
|
import {
|
||||||
createSetup,
|
createSetup,
|
||||||
@@ -28,31 +29,20 @@ export const setupToolDefinitions = [
|
|||||||
name: "list_setups",
|
name: "list_setups",
|
||||||
description:
|
description:
|
||||||
"List all gear setups with item counts and weight/cost totals.",
|
"List all gear setups with item counts and weight/cost totals.",
|
||||||
inputSchema: {
|
inputSchema: {},
|
||||||
type: "object" as const,
|
|
||||||
properties: {},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "get_setup",
|
name: "get_setup",
|
||||||
description: "Get a setup with all its items and details.",
|
description: "Get a setup with all its items and details.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
id: z.number().describe("Setup ID"),
|
||||||
properties: {
|
|
||||||
id: { type: "number", description: "Setup ID" },
|
|
||||||
},
|
|
||||||
required: ["id"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "create_setup",
|
name: "create_setup",
|
||||||
description: "Create a new gear setup (e.g. 'Bikepacking weekend').",
|
description: "Create a new gear setup (e.g. 'Bikepacking weekend').",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
name: z.string().describe("Setup name"),
|
||||||
properties: {
|
|
||||||
name: { type: "string", description: "Setup name" },
|
|
||||||
},
|
|
||||||
required: ["name"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -60,17 +50,12 @@ export const setupToolDefinitions = [
|
|||||||
description:
|
description:
|
||||||
"Update a setup's name and/or replace its item list. Pass itemIds to set exactly which items belong to this setup.",
|
"Update a setup's name and/or replace its item list. Pass itemIds to set exactly which items belong to this setup.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
id: z.number().describe("Setup ID"),
|
||||||
properties: {
|
name: z.string().optional().describe("New setup name"),
|
||||||
id: { type: "number", description: "Setup ID" },
|
itemIds: z
|
||||||
name: { type: "string", description: "New setup name" },
|
.array(z.number())
|
||||||
itemIds: {
|
.optional()
|
||||||
type: "array",
|
.describe("Array of item IDs to include in the setup"),
|
||||||
items: { type: "number" },
|
|
||||||
description: "Array of item IDs to include in the setup",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["id"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { z } from "zod";
|
||||||
import type { db as prodDb } from "../../../db/index.ts";
|
import type { db as prodDb } from "../../../db/index.ts";
|
||||||
import {
|
import {
|
||||||
createCandidate,
|
createCandidate,
|
||||||
@@ -31,14 +32,12 @@ export const threadToolDefinitions = [
|
|||||||
description:
|
description:
|
||||||
"List research threads. Threads are the recommended way to evaluate gear purchases — each thread tracks multiple candidates for a single gear slot, making it easy to compare options before committing.",
|
"List research threads. Threads are the recommended way to evaluate gear purchases — each thread tracks multiple candidates for a single gear slot, making it easy to compare options before committing.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
includeResolved: z
|
||||||
properties: {
|
.boolean()
|
||||||
includeResolved: {
|
.optional()
|
||||||
type: "boolean",
|
.describe(
|
||||||
description:
|
|
||||||
"Include resolved threads (default: false, only active threads)",
|
"Include resolved threads (default: false, only active threads)",
|
||||||
},
|
),
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -46,11 +45,7 @@ export const threadToolDefinitions = [
|
|||||||
description:
|
description:
|
||||||
"Get a thread with all its candidates for detailed comparison.",
|
"Get a thread with all its candidates for detailed comparison.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
id: z.number().describe("Thread ID"),
|
||||||
properties: {
|
|
||||||
id: { type: "number", description: "Thread ID" },
|
|
||||||
},
|
|
||||||
required: ["id"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -58,15 +53,8 @@ export const threadToolDefinitions = [
|
|||||||
description:
|
description:
|
||||||
"Start a new research thread for a gear slot. This is the preferred workflow: create a thread, add candidates with pros/cons/prices, compare them, then resolve the thread to add the winner to your collection.",
|
"Start a new research thread for a gear slot. This is the preferred workflow: create a thread, add candidates with pros/cons/prices, compare them, then resolve the thread to add the winner to your collection.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
name: z.string().describe("Thread name (e.g. 'Handlebar bag')"),
|
||||||
properties: {
|
categoryId: z.number().describe("Category ID"),
|
||||||
name: {
|
|
||||||
type: "string",
|
|
||||||
description: "Thread name (e.g. 'Handlebar bag')",
|
|
||||||
},
|
|
||||||
categoryId: { type: "number", description: "Category ID" },
|
|
||||||
},
|
|
||||||
required: ["name", "categoryId"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -74,35 +62,24 @@ export const threadToolDefinitions = [
|
|||||||
description:
|
description:
|
||||||
"Resolve a research thread by picking the winning candidate. The winner is automatically added to the gear collection as a new item, and the thread is marked as resolved.",
|
"Resolve a research thread by picking the winning candidate. The winner is automatically added to the gear collection as a new item, and the thread is marked as resolved.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
threadId: z.number().describe("Thread ID"),
|
||||||
properties: {
|
candidateId: z.number().describe("ID of the winning candidate"),
|
||||||
threadId: { type: "number", description: "Thread ID" },
|
|
||||||
candidateId: {
|
|
||||||
type: "number",
|
|
||||||
description: "ID of the winning candidate",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["threadId", "candidateId"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "add_candidate",
|
name: "add_candidate",
|
||||||
description: "Add a candidate option to a research thread for comparison.",
|
description: "Add a candidate option to a research thread for comparison.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
threadId: z.number().describe("Thread ID"),
|
||||||
properties: {
|
name: z.string().describe("Candidate name"),
|
||||||
threadId: { type: "number", description: "Thread ID" },
|
categoryId: z.number().describe("Category ID"),
|
||||||
name: { type: "string", description: "Candidate name" },
|
weightGrams: z.number().optional().describe("Weight in grams"),
|
||||||
categoryId: { type: "number", description: "Category ID" },
|
priceCents: z.number().optional().describe("Price in cents"),
|
||||||
weightGrams: { type: "number", description: "Weight in grams" },
|
notes: z.string().optional().describe("Notes"),
|
||||||
priceCents: { type: "number", description: "Price in cents" },
|
productUrl: z.string().optional().describe("Product URL"),
|
||||||
notes: { type: "string", description: "Notes" },
|
imageFilename: z.string().optional().describe("Image filename"),
|
||||||
productUrl: { type: "string", description: "Product URL" },
|
pros: z.string().optional().describe("Pros of this candidate"),
|
||||||
imageFilename: { type: "string", description: "Image filename" },
|
cons: z.string().optional().describe("Cons of this candidate"),
|
||||||
pros: { type: "string", description: "Pros of this candidate" },
|
|
||||||
cons: { type: "string", description: "Cons of this candidate" },
|
|
||||||
},
|
|
||||||
required: ["threadId", "name", "categoryId"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -110,36 +87,28 @@ export const threadToolDefinitions = [
|
|||||||
description:
|
description:
|
||||||
"Update a candidate's details (name, price, pros, cons, etc.).",
|
"Update a candidate's details (name, price, pros, cons, etc.).",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
id: z.number().describe("Candidate ID"),
|
||||||
properties: {
|
name: z.string().optional().describe("Candidate name"),
|
||||||
id: { type: "number", description: "Candidate ID" },
|
weightGrams: z.number().optional().describe("Weight in grams"),
|
||||||
name: { type: "string", description: "Candidate name" },
|
priceCents: z.number().optional().describe("Price in cents"),
|
||||||
weightGrams: { type: "number", description: "Weight in grams" },
|
categoryId: z.number().optional().describe("Category ID"),
|
||||||
priceCents: { type: "number", description: "Price in cents" },
|
notes: z.string().optional().describe("Notes"),
|
||||||
categoryId: { type: "number", description: "Category ID" },
|
productUrl: z.string().optional().describe("Product URL"),
|
||||||
notes: { type: "string", description: "Notes" },
|
imageFilename: z.string().optional().describe("Image filename"),
|
||||||
productUrl: { type: "string", description: "Product URL" },
|
imageSourceUrl: z.string().optional().describe("Image source URL"),
|
||||||
imageFilename: { type: "string", description: "Image filename" },
|
status: z
|
||||||
imageSourceUrl: { type: "string", description: "Image source URL" },
|
.string()
|
||||||
status: {
|
.optional()
|
||||||
type: "string",
|
.describe("Status: researching, ordered, or arrived"),
|
||||||
description: "Status: researching, ordered, or arrived",
|
pros: z.string().optional().describe("Pros"),
|
||||||
},
|
cons: z.string().optional().describe("Cons"),
|
||||||
pros: { type: "string", description: "Pros" },
|
|
||||||
cons: { type: "string", description: "Cons" },
|
|
||||||
},
|
|
||||||
required: ["id"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "remove_candidate",
|
name: "remove_candidate",
|
||||||
description: "Remove a candidate from a research thread.",
|
description: "Remove a candidate from a research thread.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
id: z.number().describe("Candidate ID to remove"),
|
||||||
properties: {
|
|
||||||
id: { type: "number", description: "Candidate ID to remove" },
|
|
||||||
},
|
|
||||||
required: ["id"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
271
src/server/routes/oauth.ts
Normal file
271
src/server/routes/oauth.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { db as prodDb } from "../../db/index.ts";
|
||||||
|
import { verifyPassword } from "../services/auth.service.ts";
|
||||||
|
import {
|
||||||
|
cleanExpiredOAuthData,
|
||||||
|
createAuthorizationCode,
|
||||||
|
exchangeCode,
|
||||||
|
getClient,
|
||||||
|
refreshAccessToken,
|
||||||
|
registerClient,
|
||||||
|
} from "../services/oauth.service.ts";
|
||||||
|
|
||||||
|
type Env = { Variables: { db?: any } };
|
||||||
|
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBaseUrl(c: any): string {
|
||||||
|
if (process.env.GEARBOX_URL)
|
||||||
|
return process.env.GEARBOX_URL.replace(/\/$/, "");
|
||||||
|
return new URL(c.req.url).origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLoginForm(params: {
|
||||||
|
clientName: string;
|
||||||
|
clientId: string;
|
||||||
|
redirectUri: string;
|
||||||
|
codeChallenge: string;
|
||||||
|
codeChallengeMethod: string;
|
||||||
|
state: string;
|
||||||
|
error?: string;
|
||||||
|
}): string {
|
||||||
|
const errorHtml = params.error
|
||||||
|
? `<div style="background:#fef2f2;border:1px solid #fca5a5;color:#dc2626;padding:12px;border-radius:8px;margin-bottom:16px;font-size:14px;">${escapeHtml(params.error)}</div>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Authorize - GearBox</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f9fafb; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
||||||
|
.card { background: #fff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); padding: 32px; width: 100%; max-width: 400px; }
|
||||||
|
h1 { font-size: 24px; font-weight: 700; text-align: center; margin-bottom: 8px; }
|
||||||
|
.subtitle { text-align: center; color: #6b7280; margin-bottom: 24px; font-size: 14px; }
|
||||||
|
label { display: block; font-size: 14px; font-weight: 500; margin-bottom: 4px; color: #374151; }
|
||||||
|
input[type="text"], input[type="password"] { width: 100%; padding: 10px 12px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 14px; margin-bottom: 16px; }
|
||||||
|
button { width: 100%; padding: 10px; background: #2563eb; color: #fff; border: none; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; }
|
||||||
|
button:hover { background: #1d4ed8; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>GearBox</h1>
|
||||||
|
<p class="subtitle">Authorize ${escapeHtml(params.clientName)} to access your data</p>
|
||||||
|
${errorHtml}
|
||||||
|
<form method="POST" action="/oauth/authorize">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" id="username" name="username" required>
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password" required>
|
||||||
|
<input type="hidden" name="client_id" value="${escapeHtml(params.clientId)}">
|
||||||
|
<input type="hidden" name="redirect_uri" value="${escapeHtml(params.redirectUri)}">
|
||||||
|
<input type="hidden" name="code_challenge" value="${escapeHtml(params.codeChallenge)}">
|
||||||
|
<input type="hidden" name="code_challenge_method" value="${escapeHtml(params.codeChallengeMethod)}">
|
||||||
|
<input type="hidden" name="state" value="${escapeHtml(params.state)}">
|
||||||
|
<button type="submit">Authorize</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Well-Known Route ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const wellKnownRoute = new Hono<Env>();
|
||||||
|
|
||||||
|
wellKnownRoute.get("/oauth-authorization-server", (c) => {
|
||||||
|
const baseUrl = getBaseUrl(c);
|
||||||
|
return c.json({
|
||||||
|
issuer: baseUrl,
|
||||||
|
authorization_endpoint: `${baseUrl}/oauth/authorize`,
|
||||||
|
token_endpoint: `${baseUrl}/oauth/token`,
|
||||||
|
registration_endpoint: `${baseUrl}/oauth/register`,
|
||||||
|
response_types_supported: ["code"],
|
||||||
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||||
|
code_challenge_methods_supported: ["S256"],
|
||||||
|
token_endpoint_auth_methods_supported: ["none"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── OAuth Routes ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const oauthRoutes = new Hono<Env>();
|
||||||
|
|
||||||
|
// POST /register — Dynamic Client Registration (RFC 7591)
|
||||||
|
oauthRoutes.post("/register", async (c) => {
|
||||||
|
const db = c.get("db") ?? prodDb;
|
||||||
|
const body = await c.req.json();
|
||||||
|
|
||||||
|
if (
|
||||||
|
!body.redirect_uris ||
|
||||||
|
!Array.isArray(body.redirect_uris) ||
|
||||||
|
body.redirect_uris.length === 0
|
||||||
|
) {
|
||||||
|
return c.json(
|
||||||
|
{ error: "redirect_uris is required and must be a non-empty array" },
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientName = body.client_name || "Unknown Client";
|
||||||
|
const { clientId } = registerClient(db, clientName, body.redirect_uris);
|
||||||
|
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
client_id: clientId,
|
||||||
|
client_name: clientName,
|
||||||
|
redirect_uris: body.redirect_uris,
|
||||||
|
},
|
||||||
|
201,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /authorize — Show HTML login form
|
||||||
|
oauthRoutes.get("/authorize", async (c) => {
|
||||||
|
const db = c.get("db") ?? prodDb;
|
||||||
|
|
||||||
|
const responseType = c.req.query("response_type");
|
||||||
|
const clientId = c.req.query("client_id");
|
||||||
|
const redirectUri = c.req.query("redirect_uri");
|
||||||
|
const codeChallenge = c.req.query("code_challenge");
|
||||||
|
const codeChallengeMethod = c.req.query("code_challenge_method");
|
||||||
|
const state = c.req.query("state") ?? "";
|
||||||
|
|
||||||
|
if (responseType !== "code") {
|
||||||
|
return c.json({ error: "response_type must be 'code'" }, 400);
|
||||||
|
}
|
||||||
|
if (!clientId || !redirectUri || !codeChallenge || !codeChallengeMethod) {
|
||||||
|
return c.json({ error: "Missing required parameters" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = getClient(db, clientId);
|
||||||
|
if (!client) {
|
||||||
|
return c.json({ error: "Unknown client_id" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedUris: string[] = JSON.parse(client.redirectUris);
|
||||||
|
if (!allowedUris.includes(redirectUri)) {
|
||||||
|
return c.json({ error: "redirect_uri not allowed" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.html(
|
||||||
|
renderLoginForm({
|
||||||
|
clientName: client.clientName,
|
||||||
|
clientId,
|
||||||
|
redirectUri,
|
||||||
|
codeChallenge,
|
||||||
|
codeChallengeMethod,
|
||||||
|
state,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /authorize — Process login form
|
||||||
|
oauthRoutes.post("/authorize", async (c) => {
|
||||||
|
const db = c.get("db") ?? prodDb;
|
||||||
|
const body = await c.req.parseBody();
|
||||||
|
|
||||||
|
const username = body.username as string;
|
||||||
|
const password = body.password as string;
|
||||||
|
const clientId = body.client_id as string;
|
||||||
|
const redirectUri = body.redirect_uri as string;
|
||||||
|
const codeChallenge = body.code_challenge as string;
|
||||||
|
const codeChallengeMethod = body.code_challenge_method as string;
|
||||||
|
const state = (body.state as string) ?? "";
|
||||||
|
|
||||||
|
const user = await verifyPassword(db, username, password);
|
||||||
|
if (!user) {
|
||||||
|
const client = getClient(db, clientId);
|
||||||
|
return c.html(
|
||||||
|
renderLoginForm({
|
||||||
|
clientName: client?.clientName ?? "Unknown",
|
||||||
|
clientId,
|
||||||
|
redirectUri,
|
||||||
|
codeChallenge,
|
||||||
|
codeChallengeMethod,
|
||||||
|
state,
|
||||||
|
error: "Invalid username or password",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { code } = createAuthorizationCode(
|
||||||
|
db,
|
||||||
|
clientId,
|
||||||
|
codeChallenge,
|
||||||
|
codeChallengeMethod,
|
||||||
|
redirectUri,
|
||||||
|
);
|
||||||
|
|
||||||
|
const url = new URL(redirectUri);
|
||||||
|
url.searchParams.set("code", code);
|
||||||
|
if (state) url.searchParams.set("state", state);
|
||||||
|
|
||||||
|
return c.redirect(url.toString(), 302);
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /token — Token exchange
|
||||||
|
oauthRoutes.post("/token", async (c) => {
|
||||||
|
const db = c.get("db") ?? prodDb;
|
||||||
|
const body = await c.req.parseBody();
|
||||||
|
|
||||||
|
const grantType = body.grant_type as string;
|
||||||
|
|
||||||
|
// Opportunistic cleanup
|
||||||
|
cleanExpiredOAuthData(db);
|
||||||
|
|
||||||
|
if (grantType === "authorization_code") {
|
||||||
|
const code = body.code as string;
|
||||||
|
const codeVerifier = body.code_verifier as string;
|
||||||
|
const clientId = body.client_id as string;
|
||||||
|
const redirectUri = body.redirect_uri as string;
|
||||||
|
|
||||||
|
const result = await exchangeCode(
|
||||||
|
db,
|
||||||
|
code,
|
||||||
|
codeVerifier,
|
||||||
|
clientId,
|
||||||
|
redirectUri,
|
||||||
|
);
|
||||||
|
if (!result) {
|
||||||
|
return c.json({ error: "invalid_grant" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
access_token: result.accessToken,
|
||||||
|
refresh_token: result.refreshToken,
|
||||||
|
token_type: "Bearer",
|
||||||
|
expires_in: result.expiresIn,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grantType === "refresh_token") {
|
||||||
|
const refreshToken = body.refresh_token as string;
|
||||||
|
const clientId = body.client_id as string;
|
||||||
|
|
||||||
|
const result = await refreshAccessToken(db, refreshToken, clientId);
|
||||||
|
if (!result) {
|
||||||
|
return c.json({ error: "invalid_grant" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
access_token: result.accessToken,
|
||||||
|
refresh_token: result.refreshToken,
|
||||||
|
token_type: "Bearer",
|
||||||
|
expires_in: result.expiresIn,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ error: "unsupported_grant_type" }, 400);
|
||||||
|
});
|
||||||
184
src/server/services/oauth.service.ts
Normal file
184
src/server/services/oauth.service.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||||
|
import { and, eq, lt } from "drizzle-orm";
|
||||||
|
import { db as prodDb } from "../../db/index.ts";
|
||||||
|
import { oauthClients, oauthCodes, oauthTokens } from "../../db/schema.ts";
|
||||||
|
|
||||||
|
type Db = typeof prodDb;
|
||||||
|
|
||||||
|
// ── Client Registration ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function registerClient(
|
||||||
|
db: Db = prodDb,
|
||||||
|
clientName: string,
|
||||||
|
redirectUris: string[],
|
||||||
|
): { clientId: string } {
|
||||||
|
const clientId = randomUUID();
|
||||||
|
const redirectUrisJson = JSON.stringify(redirectUris);
|
||||||
|
|
||||||
|
db.insert(oauthClients)
|
||||||
|
.values({ clientId, clientName, redirectUris: redirectUrisJson })
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return { clientId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getClient(db: Db = prodDb, clientId: string) {
|
||||||
|
return (
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(oauthClients)
|
||||||
|
.where(eq(oauthClients.clientId, clientId))
|
||||||
|
.get() ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Authorization Code ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function createAuthorizationCode(
|
||||||
|
db: Db = prodDb,
|
||||||
|
clientId: string,
|
||||||
|
codeChallenge: string,
|
||||||
|
codeChallengeMethod: string,
|
||||||
|
redirectUri: string,
|
||||||
|
): { code: string } {
|
||||||
|
const code = randomBytes(32).toString("hex");
|
||||||
|
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
|
||||||
|
|
||||||
|
db.insert(oauthCodes)
|
||||||
|
.values({
|
||||||
|
code,
|
||||||
|
clientId,
|
||||||
|
codeChallenge,
|
||||||
|
codeChallengeMethod,
|
||||||
|
redirectUri,
|
||||||
|
expiresAt,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return { code };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exchangeCode(
|
||||||
|
db: Db = prodDb,
|
||||||
|
code: string,
|
||||||
|
codeVerifier: string,
|
||||||
|
clientId: string,
|
||||||
|
redirectUri: string,
|
||||||
|
): Promise<{
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
expiresIn: number;
|
||||||
|
} | null> {
|
||||||
|
const record = db
|
||||||
|
.select()
|
||||||
|
.from(oauthCodes)
|
||||||
|
.where(eq(oauthCodes.code, code))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!record) return null;
|
||||||
|
if (record.used !== 0) return null;
|
||||||
|
if (record.clientId !== clientId) return null;
|
||||||
|
if (record.redirectUri !== redirectUri) return null;
|
||||||
|
if (record.expiresAt < new Date()) return null;
|
||||||
|
|
||||||
|
// Verify PKCE: SHA-256(verifier) encoded as base64url must match stored challenge
|
||||||
|
const computedChallenge = createHash("sha256")
|
||||||
|
.update(codeVerifier)
|
||||||
|
.digest("base64url");
|
||||||
|
|
||||||
|
if (computedChallenge !== record.codeChallenge) return null;
|
||||||
|
|
||||||
|
// Mark code as used
|
||||||
|
db.update(oauthCodes).set({ used: 1 }).where(eq(oauthCodes.code, code)).run();
|
||||||
|
|
||||||
|
return generateTokens(db, clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Token Management ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function generateTokens(
|
||||||
|
db: Db,
|
||||||
|
clientId: string,
|
||||||
|
): { accessToken: string; refreshToken: string; expiresIn: number } {
|
||||||
|
const accessToken = randomBytes(32).toString("hex");
|
||||||
|
const refreshToken = randomBytes(32).toString("hex");
|
||||||
|
|
||||||
|
const accessTokenHash = createHash("sha256")
|
||||||
|
.update(accessToken)
|
||||||
|
.digest("hex");
|
||||||
|
const refreshTokenHash = createHash("sha256")
|
||||||
|
.update(refreshToken)
|
||||||
|
.digest("hex");
|
||||||
|
|
||||||
|
const expiresAt = new Date(Date.now() + 3600 * 1000); // 1 hour
|
||||||
|
const refreshExpiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
|
||||||
|
|
||||||
|
db.insert(oauthTokens)
|
||||||
|
.values({
|
||||||
|
accessTokenHash,
|
||||||
|
refreshTokenHash,
|
||||||
|
clientId,
|
||||||
|
expiresAt,
|
||||||
|
refreshExpiresAt,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return { accessToken, refreshToken, expiresIn: 3600 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyAccessToken(
|
||||||
|
db: Db = prodDb,
|
||||||
|
token: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const tokenHash = createHash("sha256").update(token).digest("hex");
|
||||||
|
|
||||||
|
const record = db
|
||||||
|
.select()
|
||||||
|
.from(oauthTokens)
|
||||||
|
.where(eq(oauthTokens.accessTokenHash, tokenHash))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!record) return false;
|
||||||
|
if (record.expiresAt < new Date()) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshAccessToken(
|
||||||
|
db: Db = prodDb,
|
||||||
|
refreshToken: string,
|
||||||
|
clientId: string,
|
||||||
|
): Promise<{
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
expiresIn: number;
|
||||||
|
} | null> {
|
||||||
|
const tokenHash = createHash("sha256").update(refreshToken).digest("hex");
|
||||||
|
|
||||||
|
const record = db
|
||||||
|
.select()
|
||||||
|
.from(oauthTokens)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(oauthTokens.refreshTokenHash, tokenHash),
|
||||||
|
eq(oauthTokens.clientId, clientId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!record) return null;
|
||||||
|
if (record.refreshExpiresAt < new Date()) return null;
|
||||||
|
|
||||||
|
// Delete old token pair
|
||||||
|
db.delete(oauthTokens).where(eq(oauthTokens.id, record.id)).run();
|
||||||
|
|
||||||
|
return generateTokens(db, clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cleanup ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function cleanExpiredOAuthData(db: Db = prodDb): void {
|
||||||
|
const now = new Date();
|
||||||
|
db.delete(oauthCodes).where(lt(oauthCodes.expiresAt, now)).run();
|
||||||
|
db.delete(oauthTokens).where(lt(oauthTokens.expiresAt, now)).run();
|
||||||
|
}
|
||||||
419
tests/routes/oauth.test.ts
Normal file
419
tests/routes/oauth.test.ts
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "bun:test";
|
||||||
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { mcpRoutes } from "../../src/server/mcp/index.ts";
|
||||||
|
import { oauthRoutes, wellKnownRoute } from "../../src/server/routes/oauth.ts";
|
||||||
|
import { createUser } from "../../src/server/services/auth.service.ts";
|
||||||
|
import { createTestDb } from "../helpers/db.ts";
|
||||||
|
|
||||||
|
function createTestApp() {
|
||||||
|
const db = createTestDb();
|
||||||
|
const app = new Hono<{ Variables: { db?: any } }>();
|
||||||
|
app.use("*", async (c, next) => {
|
||||||
|
c.set("db", db);
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
app.route("/.well-known", wellKnownRoute);
|
||||||
|
app.route("/oauth", oauthRoutes);
|
||||||
|
return { app, db };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFullTestApp() {
|
||||||
|
const db = createTestDb();
|
||||||
|
const app = new Hono<{ Variables: { db?: any } }>();
|
||||||
|
app.use("*", async (c, next) => {
|
||||||
|
c.set("db", db);
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
app.route("/.well-known", wellKnownRoute);
|
||||||
|
app.route("/oauth", oauthRoutes);
|
||||||
|
app.route("/mcp", mcpRoutes);
|
||||||
|
return { app, db };
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatePkce() {
|
||||||
|
const verifier = randomBytes(32).toString("hex");
|
||||||
|
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||||
|
return { verifier, challenge };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("OAuth Routes", () => {
|
||||||
|
let app: Hono;
|
||||||
|
let db: ReturnType<typeof createTestDb>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const testApp = createTestApp();
|
||||||
|
app = testApp.app;
|
||||||
|
db = testApp.db;
|
||||||
|
await createUser(db, "admin", "secret123");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /.well-known/oauth-authorization-server", () => {
|
||||||
|
it("returns 200 with correct metadata fields", async () => {
|
||||||
|
const res = await app.request("/.well-known/oauth-authorization-server");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.issuer).toBeDefined();
|
||||||
|
expect(body.authorization_endpoint).toContain("/oauth/authorize");
|
||||||
|
expect(body.token_endpoint).toContain("/oauth/token");
|
||||||
|
expect(body.registration_endpoint).toContain("/oauth/register");
|
||||||
|
expect(body.response_types_supported).toEqual(["code"]);
|
||||||
|
expect(body.grant_types_supported).toEqual([
|
||||||
|
"authorization_code",
|
||||||
|
"refresh_token",
|
||||||
|
]);
|
||||||
|
expect(body.code_challenge_methods_supported).toEqual(["S256"]);
|
||||||
|
expect(body.token_endpoint_auth_methods_supported).toEqual(["none"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /oauth/register", () => {
|
||||||
|
it("returns 201 with client_id, client_name, redirect_uris", async () => {
|
||||||
|
const res = await app.request("/oauth/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
client_name: "Test Client",
|
||||||
|
redirect_uris: ["http://localhost:3000/callback"],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.client_id).toBeDefined();
|
||||||
|
expect(body.client_name).toBe("Test Client");
|
||||||
|
expect(body.redirect_uris).toEqual(["http://localhost:3000/callback"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 without redirect_uris", async () => {
|
||||||
|
const res = await app.request("/oauth/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ client_name: "Test Client" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /oauth/authorize", () => {
|
||||||
|
it("returns 200 HTML with form when params are valid", async () => {
|
||||||
|
// Register a client first
|
||||||
|
const regRes = await app.request("/oauth/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
client_name: "Test Client",
|
||||||
|
redirect_uris: ["http://localhost:3000/callback"],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const { client_id } = await regRes.json();
|
||||||
|
const { challenge } = generatePkce();
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
response_type: "code",
|
||||||
|
client_id,
|
||||||
|
redirect_uri: "http://localhost:3000/callback",
|
||||||
|
code_challenge: challenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
state: "abc123",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request(`/oauth/authorize?${params}`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const html = await res.text();
|
||||||
|
expect(html).toContain("GearBox");
|
||||||
|
expect(html).toContain("password");
|
||||||
|
expect(html).toContain("Test Client");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 with invalid client_id", async () => {
|
||||||
|
const { challenge } = generatePkce();
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
response_type: "code",
|
||||||
|
client_id: "nonexistent",
|
||||||
|
redirect_uri: "http://localhost:3000/callback",
|
||||||
|
code_challenge: challenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
state: "abc123",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request(`/oauth/authorize?${params}`);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /oauth/authorize", () => {
|
||||||
|
it("returns 200 HTML with error on wrong password", async () => {
|
||||||
|
// Register a client
|
||||||
|
const regRes = await app.request("/oauth/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
client_name: "Test Client",
|
||||||
|
redirect_uris: ["http://localhost:3000/callback"],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const { client_id } = await regRes.json();
|
||||||
|
const { challenge } = generatePkce();
|
||||||
|
|
||||||
|
const formBody = new URLSearchParams({
|
||||||
|
username: "admin",
|
||||||
|
password: "wrongpassword",
|
||||||
|
client_id,
|
||||||
|
redirect_uri: "http://localhost:3000/callback",
|
||||||
|
code_challenge: challenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
state: "abc123",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request("/oauth/authorize", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: formBody.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const html = await res.text();
|
||||||
|
expect(html).toContain("Invalid");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Full OAuth flow", () => {
|
||||||
|
it("register → authorize → token exchange", async () => {
|
||||||
|
// 1. Register client
|
||||||
|
const regRes = await app.request("/oauth/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
client_name: "Test Client",
|
||||||
|
redirect_uris: ["http://localhost:3000/callback"],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const { client_id } = await regRes.json();
|
||||||
|
const { verifier, challenge } = generatePkce();
|
||||||
|
|
||||||
|
// 2. POST /oauth/authorize with correct credentials
|
||||||
|
const formBody = new URLSearchParams({
|
||||||
|
username: "admin",
|
||||||
|
password: "secret123",
|
||||||
|
client_id,
|
||||||
|
redirect_uri: "http://localhost:3000/callback",
|
||||||
|
code_challenge: challenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
state: "mystate",
|
||||||
|
});
|
||||||
|
|
||||||
|
const authRes = await app.request("/oauth/authorize", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: formBody.toString(),
|
||||||
|
redirect: "manual",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(authRes.status).toBe(302);
|
||||||
|
const location = authRes.headers.get("location")!;
|
||||||
|
expect(location).toContain("code=");
|
||||||
|
expect(location).toContain("state=mystate");
|
||||||
|
|
||||||
|
const redirectUrl = new URL(location);
|
||||||
|
const code = redirectUrl.searchParams.get("code")!;
|
||||||
|
|
||||||
|
// 3. Exchange code for tokens
|
||||||
|
const tokenBody = new URLSearchParams({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code,
|
||||||
|
code_verifier: verifier,
|
||||||
|
client_id,
|
||||||
|
redirect_uri: "http://localhost:3000/callback",
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenRes = await app.request("/oauth/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: tokenBody.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(tokenRes.status).toBe(200);
|
||||||
|
const tokens = await tokenRes.json();
|
||||||
|
expect(tokens.access_token).toBeDefined();
|
||||||
|
expect(tokens.refresh_token).toBeDefined();
|
||||||
|
expect(tokens.token_type).toBe("Bearer");
|
||||||
|
expect(tokens.expires_in).toBe(3600);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Full OAuth → MCP Flow", () => {
|
||||||
|
it("complete flow: register → authorize → token → MCP call", async () => {
|
||||||
|
const { app, db } = createFullTestApp();
|
||||||
|
await createUser(db, "admin", "secret123");
|
||||||
|
const { verifier, challenge } = generatePkce();
|
||||||
|
|
||||||
|
// 1. Register client
|
||||||
|
const regRes = await app.request("/oauth/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
client_name: "Claude",
|
||||||
|
redirect_uris: ["http://localhost/cb"],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const { client_id } = await regRes.json();
|
||||||
|
|
||||||
|
// 2. Authorize (simulate form POST)
|
||||||
|
const authRes = await app.request(
|
||||||
|
`/oauth/authorize?response_type=code&client_id=${client_id}&redirect_uri=${encodeURIComponent("http://localhost/cb")}&code_challenge=${challenge}&code_challenge_method=S256&state=test`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
username: "admin",
|
||||||
|
password: "secret123",
|
||||||
|
client_id,
|
||||||
|
redirect_uri: "http://localhost/cb",
|
||||||
|
code_challenge: challenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
state: "test",
|
||||||
|
}).toString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const code = new URL(authRes.headers.get("location")!).searchParams.get(
|
||||||
|
"code",
|
||||||
|
)!;
|
||||||
|
|
||||||
|
// 3. Exchange code for tokens
|
||||||
|
const tokenRes = await app.request("/oauth/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code,
|
||||||
|
code_verifier: verifier,
|
||||||
|
client_id,
|
||||||
|
redirect_uri: "http://localhost/cb",
|
||||||
|
}).toString(),
|
||||||
|
});
|
||||||
|
const { access_token } = await tokenRes.json();
|
||||||
|
|
||||||
|
// 4. Use token to call MCP
|
||||||
|
const mcpRes = await app.request("/mcp", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json, text/event-stream",
|
||||||
|
Authorization: `Bearer ${access_token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "initialize",
|
||||||
|
params: {
|
||||||
|
protocolVersion: "2025-03-26",
|
||||||
|
capabilities: {},
|
||||||
|
clientInfo: { name: "test", version: "1.0" },
|
||||||
|
},
|
||||||
|
id: 1,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mcpRes.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects MCP call without auth when user exists", async () => {
|
||||||
|
const { app, db } = createFullTestApp();
|
||||||
|
await createUser(db, "admin", "secret123");
|
||||||
|
|
||||||
|
const mcpRes = await app.request("/mcp", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "initialize",
|
||||||
|
params: {
|
||||||
|
protocolVersion: "2025-03-26",
|
||||||
|
capabilities: {},
|
||||||
|
clientInfo: { name: "test", version: "1.0" },
|
||||||
|
},
|
||||||
|
id: 1,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mcpRes.status).toBe(401);
|
||||||
|
expect(mcpRes.headers.get("www-authenticate")).toContain("Bearer");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Token refresh", () => {
|
||||||
|
it("exchanges refresh token for new tokens", async () => {
|
||||||
|
// Full flow to get initial tokens
|
||||||
|
const regRes = await app.request("/oauth/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
client_name: "Test Client",
|
||||||
|
redirect_uris: ["http://localhost:3000/callback"],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const { client_id } = await regRes.json();
|
||||||
|
const { verifier, challenge } = generatePkce();
|
||||||
|
|
||||||
|
const formBody = new URLSearchParams({
|
||||||
|
username: "admin",
|
||||||
|
password: "secret123",
|
||||||
|
client_id,
|
||||||
|
redirect_uri: "http://localhost:3000/callback",
|
||||||
|
code_challenge: challenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
state: "s",
|
||||||
|
});
|
||||||
|
|
||||||
|
const authRes = await app.request("/oauth/authorize", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: formBody.toString(),
|
||||||
|
redirect: "manual",
|
||||||
|
});
|
||||||
|
|
||||||
|
const location = authRes.headers.get("location")!;
|
||||||
|
const code = new URL(location).searchParams.get("code")!;
|
||||||
|
|
||||||
|
const tokenBody = new URLSearchParams({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code,
|
||||||
|
code_verifier: verifier,
|
||||||
|
client_id,
|
||||||
|
redirect_uri: "http://localhost:3000/callback",
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenRes = await app.request("/oauth/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: tokenBody.toString(),
|
||||||
|
});
|
||||||
|
const tokens = await tokenRes.json();
|
||||||
|
|
||||||
|
// Now refresh
|
||||||
|
const refreshBody = new URLSearchParams({
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: tokens.refresh_token,
|
||||||
|
client_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshRes = await app.request("/oauth/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: refreshBody.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(refreshRes.status).toBe(200);
|
||||||
|
const newTokens = await refreshRes.json();
|
||||||
|
expect(newTokens.access_token).toBeDefined();
|
||||||
|
expect(newTokens.refresh_token).toBeDefined();
|
||||||
|
expect(newTokens.token_type).toBe("Bearer");
|
||||||
|
expect(newTokens.expires_in).toBe(3600);
|
||||||
|
// Should be different tokens
|
||||||
|
expect(newTokens.access_token).not.toBe(tokens.access_token);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
290
tests/services/oauth.service.test.ts
Normal file
290
tests/services/oauth.service.test.ts
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "bun:test";
|
||||||
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
import {
|
||||||
|
createAuthorizationCode,
|
||||||
|
exchangeCode,
|
||||||
|
getClient,
|
||||||
|
refreshAccessToken,
|
||||||
|
registerClient,
|
||||||
|
verifyAccessToken,
|
||||||
|
} from "../../src/server/services/oauth.service.ts";
|
||||||
|
import { createTestDb } from "../helpers/db.ts";
|
||||||
|
|
||||||
|
function generatePkce() {
|
||||||
|
const verifier = randomBytes(32).toString("hex");
|
||||||
|
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||||
|
return { verifier, challenge };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("OAuth Service", () => {
|
||||||
|
let db: ReturnType<typeof createTestDb>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = createTestDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Client Registration", () => {
|
||||||
|
it("registers a client and returns clientId (string, non-empty)", () => {
|
||||||
|
const result = registerClient(db, "Test App", [
|
||||||
|
"http://localhost:8080/callback",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(typeof result.clientId).toBe("string");
|
||||||
|
expect(result.clientId.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getClient returns registered client with correct clientName and redirectUris (JSON parsed)", () => {
|
||||||
|
const redirectUris = ["http://localhost:8080/callback"];
|
||||||
|
const { clientId } = registerClient(db, "Test App", redirectUris);
|
||||||
|
|
||||||
|
const client = getClient(db, clientId);
|
||||||
|
|
||||||
|
expect(client).not.toBeNull();
|
||||||
|
expect(client!.clientName).toBe("Test App");
|
||||||
|
expect(JSON.parse(client!.redirectUris)).toEqual(redirectUris);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getClient returns null for unknown client", () => {
|
||||||
|
const client = getClient(db, "unknown-client-id");
|
||||||
|
|
||||||
|
expect(client).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Authorization Code + PKCE", () => {
|
||||||
|
it("creates an authorization code (non-empty)", () => {
|
||||||
|
const { clientId } = registerClient(db, "Test App", [
|
||||||
|
"http://localhost:8080/callback",
|
||||||
|
]);
|
||||||
|
const { challenge } = generatePkce();
|
||||||
|
|
||||||
|
const result = createAuthorizationCode(
|
||||||
|
db,
|
||||||
|
clientId,
|
||||||
|
challenge,
|
||||||
|
"S256",
|
||||||
|
"http://localhost:8080/callback",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(typeof result.code).toBe("string");
|
||||||
|
expect(result.code.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exchanges code for tokens with valid PKCE verifier (returns accessToken, refreshToken, expiresIn=3600)", async () => {
|
||||||
|
const { clientId } = registerClient(db, "Test App", [
|
||||||
|
"http://localhost:8080/callback",
|
||||||
|
]);
|
||||||
|
const { verifier, challenge } = generatePkce();
|
||||||
|
|
||||||
|
const { code } = createAuthorizationCode(
|
||||||
|
db,
|
||||||
|
clientId,
|
||||||
|
challenge,
|
||||||
|
"S256",
|
||||||
|
"http://localhost:8080/callback",
|
||||||
|
);
|
||||||
|
|
||||||
|
const tokens = await exchangeCode(
|
||||||
|
db,
|
||||||
|
code,
|
||||||
|
verifier,
|
||||||
|
clientId,
|
||||||
|
"http://localhost:8080/callback",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tokens).not.toBeNull();
|
||||||
|
expect(typeof tokens!.accessToken).toBe("string");
|
||||||
|
expect(tokens!.accessToken.length).toBeGreaterThan(0);
|
||||||
|
expect(typeof tokens!.refreshToken).toBe("string");
|
||||||
|
expect(tokens!.refreshToken.length).toBeGreaterThan(0);
|
||||||
|
expect(tokens!.expiresIn).toBe(3600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects code exchange with wrong PKCE verifier (returns null)", async () => {
|
||||||
|
const { clientId } = registerClient(db, "Test App", [
|
||||||
|
"http://localhost:8080/callback",
|
||||||
|
]);
|
||||||
|
const { challenge } = generatePkce();
|
||||||
|
|
||||||
|
const { code } = createAuthorizationCode(
|
||||||
|
db,
|
||||||
|
clientId,
|
||||||
|
challenge,
|
||||||
|
"S256",
|
||||||
|
"http://localhost:8080/callback",
|
||||||
|
);
|
||||||
|
|
||||||
|
const tokens = await exchangeCode(
|
||||||
|
db,
|
||||||
|
code,
|
||||||
|
"wrongverifier",
|
||||||
|
clientId,
|
||||||
|
"http://localhost:8080/callback",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tokens).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects code exchange with wrong redirect_uri (returns null)", async () => {
|
||||||
|
const { clientId } = registerClient(db, "Test App", [
|
||||||
|
"http://localhost:8080/callback",
|
||||||
|
]);
|
||||||
|
const { verifier, challenge } = generatePkce();
|
||||||
|
|
||||||
|
const { code } = createAuthorizationCode(
|
||||||
|
db,
|
||||||
|
clientId,
|
||||||
|
challenge,
|
||||||
|
"S256",
|
||||||
|
"http://localhost:8080/callback",
|
||||||
|
);
|
||||||
|
|
||||||
|
const tokens = await exchangeCode(
|
||||||
|
db,
|
||||||
|
code,
|
||||||
|
verifier,
|
||||||
|
clientId,
|
||||||
|
"http://localhost:9999/wrong",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tokens).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects replayed code - single use (second exchange returns null)", async () => {
|
||||||
|
const { clientId } = registerClient(db, "Test App", [
|
||||||
|
"http://localhost:8080/callback",
|
||||||
|
]);
|
||||||
|
const { verifier, challenge } = generatePkce();
|
||||||
|
|
||||||
|
const { code } = createAuthorizationCode(
|
||||||
|
db,
|
||||||
|
clientId,
|
||||||
|
challenge,
|
||||||
|
"S256",
|
||||||
|
"http://localhost:8080/callback",
|
||||||
|
);
|
||||||
|
|
||||||
|
// First exchange succeeds
|
||||||
|
const first = await exchangeCode(
|
||||||
|
db,
|
||||||
|
code,
|
||||||
|
verifier,
|
||||||
|
clientId,
|
||||||
|
"http://localhost:8080/callback",
|
||||||
|
);
|
||||||
|
expect(first).not.toBeNull();
|
||||||
|
|
||||||
|
// Second exchange is rejected
|
||||||
|
const second = await exchangeCode(
|
||||||
|
db,
|
||||||
|
code,
|
||||||
|
verifier,
|
||||||
|
clientId,
|
||||||
|
"http://localhost:8080/callback",
|
||||||
|
);
|
||||||
|
expect(second).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Token Verification", () => {
|
||||||
|
it("verifies a valid access token (returns true)", async () => {
|
||||||
|
const { clientId } = registerClient(db, "Test App", [
|
||||||
|
"http://localhost:8080/callback",
|
||||||
|
]);
|
||||||
|
const { verifier, challenge } = generatePkce();
|
||||||
|
|
||||||
|
const { code } = createAuthorizationCode(
|
||||||
|
db,
|
||||||
|
clientId,
|
||||||
|
challenge,
|
||||||
|
"S256",
|
||||||
|
"http://localhost:8080/callback",
|
||||||
|
);
|
||||||
|
|
||||||
|
const tokens = await exchangeCode(
|
||||||
|
db,
|
||||||
|
code,
|
||||||
|
verifier,
|
||||||
|
clientId,
|
||||||
|
"http://localhost:8080/callback",
|
||||||
|
);
|
||||||
|
|
||||||
|
const isValid = await verifyAccessToken(db, tokens!.accessToken);
|
||||||
|
expect(isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown token (returns false)", async () => {
|
||||||
|
const isValid = await verifyAccessToken(db, "unknowntoken12345678");
|
||||||
|
expect(isValid).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Token Refresh", () => {
|
||||||
|
it("refreshes a valid refresh token and returns new tokens (different accessToken)", async () => {
|
||||||
|
const { clientId } = registerClient(db, "Test App", [
|
||||||
|
"http://localhost:8080/callback",
|
||||||
|
]);
|
||||||
|
const { verifier, challenge } = generatePkce();
|
||||||
|
|
||||||
|
const { code } = createAuthorizationCode(
|
||||||
|
db,
|
||||||
|
clientId,
|
||||||
|
challenge,
|
||||||
|
"S256",
|
||||||
|
"http://localhost:8080/callback",
|
||||||
|
);
|
||||||
|
|
||||||
|
const tokens = await exchangeCode(
|
||||||
|
db,
|
||||||
|
code,
|
||||||
|
verifier,
|
||||||
|
clientId,
|
||||||
|
"http://localhost:8080/callback",
|
||||||
|
);
|
||||||
|
|
||||||
|
const newTokens = await refreshAccessToken(
|
||||||
|
db,
|
||||||
|
tokens!.refreshToken,
|
||||||
|
clientId,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(newTokens).not.toBeNull();
|
||||||
|
expect(newTokens!.accessToken).not.toBe(tokens!.accessToken);
|
||||||
|
expect(typeof newTokens!.refreshToken).toBe("string");
|
||||||
|
expect(newTokens!.expiresIn).toBe(3600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects refresh with wrong clientId (returns null)", async () => {
|
||||||
|
const { clientId } = registerClient(db, "Test App", [
|
||||||
|
"http://localhost:8080/callback",
|
||||||
|
]);
|
||||||
|
const { verifier, challenge } = generatePkce();
|
||||||
|
|
||||||
|
const { code } = createAuthorizationCode(
|
||||||
|
db,
|
||||||
|
clientId,
|
||||||
|
challenge,
|
||||||
|
"S256",
|
||||||
|
"http://localhost:8080/callback",
|
||||||
|
);
|
||||||
|
|
||||||
|
const tokens = await exchangeCode(
|
||||||
|
db,
|
||||||
|
code,
|
||||||
|
verifier,
|
||||||
|
clientId,
|
||||||
|
"http://localhost:8080/callback",
|
||||||
|
);
|
||||||
|
|
||||||
|
const newTokens = await refreshAccessToken(
|
||||||
|
db,
|
||||||
|
tokens!.refreshToken,
|
||||||
|
"wrong-client-id",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(newTokens).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user