20 Commits

Author SHA1 Message Date
e34a2cad11 docs: add authentication, API reference, and MCP server guides
Some checks failed
CI / ci (push) Failing after 11s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:00:03 +02:00
790fc07f5a docs: expand CLAUDE.md with auth details and MCP tool reference
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:55:59 +02:00
4148833644 fix: only show onboarding wizard after account setup
The wizard creates categories via POST which requires auth.
Gate the wizard on isAuthenticated so users create their
account first via Sign In, then the wizard appears.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:49:44 +02:00
17d76761bb fix: address code review issues — MCP auth, error handling, password route
- MCP auth middleware now rejects requests without API key when users exist
- Image /from-url route distinguishes validation errors (400) from server errors (500)
- Password change route returns 401 when no session cookie instead of crashing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:42:34 +02:00
ba9662aeaf docs: add MCP server configuration and auth docs to CLAUDE.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:39:07 +02:00
6f51432d42 feat: add MCP server with streamable HTTP transport at /mcp
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:38:18 +02:00
8919829167 feat: add MCP tool handlers, definitions, and collection resource
Wrap existing service layer with MCP-compatible tool handlers for items,
categories, threads/candidates, setups, and image fetching. Add collection
summary resource for overview data. All 14 MCP-specific tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:35:27 +02:00
a10156142f chore: install @modelcontextprotocol/sdk
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:31:46 +02:00
5bb728e545 feat: add password change and API key management to settings
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:30:50 +02:00
511fece4c7 feat: add login button to header and conditional edit UI
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:29:01 +02:00
87a367d41b feat: add useAuth hook and login page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:27:23 +02:00
66dc8ec8ee feat: register auth routes and apply write-protection middleware
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:25:51 +02:00
e0e7bfce3e feat: add auth routes for login, setup, and API key management
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:24:26 +02:00
8138458d8d feat: add auth middleware for write endpoint protection
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:22:00 +02:00
7c4fa9d9d2 feat: add auth service with user, session, and API key management
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:20:27 +02:00
32c7b41ce5 feat: add users, sessions, and api_keys tables
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:18:07 +02:00
b3a13fa974 feat: add POST /api/images/from-url route
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:17:10 +02:00
0004329895 feat: add image URL fetching service with tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:15:56 +02:00
d104e9788f feat: add imageSourceUrl to Zod schemas and service functions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:14:13 +02:00
1eb4a786ce feat: add imageSourceUrl column to items and threadCandidates
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:12:33 +02:00
41 changed files with 5249 additions and 4 deletions

View File

@@ -53,7 +53,7 @@ bun run build # Vite build → dist/client/
- **Schema**: `schema.ts` — Drizzle table definitions for SQLite.
- **Prices stored as cents** (`priceCents: integer`) to avoid float rounding.
- **Timestamps**: stored as integers (unix epoch) with `{ mode: "timestamp" }`.
- Tables: `categories`, `items`, `threads`, `threadCandidates`, `setups`, `setupItems`, `settings`.
- Tables: `categories`, `items`, `threads`, `threadCandidates`, `setups`, `setupItems`, `settings`, `users`, `sessions`, `apiKeys`.
### Testing (`tests/`)
- Bun test runner. Tests at service level and route level.
@@ -68,4 +68,80 @@ bun run build # Vite build → dist/client/
- **Thread resolution**: Resolving a thread copies the winning candidate's data into a new item in the collection, sets `resolvedCandidateId`, and changes status to "resolved".
- **Setup item sync**: `PUT /api/setups/:id/items` replaces all setup_items atomically (delete all, re-insert).
- **Image uploads**: `POST /api/images` saves to `./uploads/` with UUID filename, returned as `imageFilename` on item/candidate records.
- **Image URL fetching**: `POST /api/images/from-url` fetches an image from a URL, saves locally, returns `{ filename, sourceUrl }`.
- **Aggregates** (weight/cost totals): Computed via SQL on read, not stored on records.
- **Authentication**: Public-read, authenticated-write. Cookie sessions for web UI, API keys (`X-API-Key` header) for programmatic access. `POST /api/auth/setup` for first-time account creation. Auth middleware protects all POST/PUT/DELETE on `/api/*` except `/api/auth/*`.
## Authentication
- **First run**: No users exist. Visit `/login` to create your admin account.
- **Web UI**: Cookie-based sessions (`gearbox_session`), 30-day expiry, auto-refreshed.
- **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.
- **Auth routes**: `/api/auth/login`, `/api/auth/logout`, `/api/auth/setup`, `/api/auth/me`, `/api/auth/password`, `/api/auth/keys`.
## 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.
### Tools (19 total)
| Tool | Description |
|------|-------------|
| `list_items` | List all items, optionally filter by category |
| `get_item` | Get item details by ID |
| `create_item` | Add item to collection (for decided items; use `create_thread` for research) |
| `update_item` | Update item details |
| `delete_item` | Remove item from collection |
| `list_categories` | List all categories |
| `create_category` | Create a new category |
| `list_threads` | List research threads (recommended workflow for gear purchases) |
| `get_thread` | Get thread with candidates |
| `create_thread` | Start a research thread to compare gear options |
| `resolve_thread` | Pick winning candidate, adds it to collection |
| `add_candidate` | Add candidate to a research thread |
| `update_candidate` | Update candidate details |
| `remove_candidate` | Remove candidate from thread |
| `list_setups` | List gear setups |
| `get_setup` | Get setup with items and totals |
| `create_setup` | Create a new setup |
| `update_setup` | Update setup name or items |
| `upload_image_from_url` | Fetch image from URL, save locally |
### Resources
- `gearbox://collection/summary` — Overview of collection: totals, items per category, active threads.
### Configuration
**Claude Code** (`.claude/settings.json`):
```json
{
"mcpServers": {
"gearbox": {
"type": "streamable-http",
"url": "http://localhost:3000/mcp",
"headers": {
"X-API-Key": "<your-api-key>"
}
}
}
}
```
**Claude Desktop** (`claude_desktop_config.json`):
```json
{
"mcpServers": {
"gearbox": {
"type": "streamable-http",
"url": "http://localhost:3000/mcp",
"headers": {
"X-API-Key": "<your-api-key>"
}
}
}
}
```
Generate an API key from Settings > API Keys after logging in.

169
bun.lock
View File

@@ -6,6 +6,7 @@
"name": "gearbox",
"dependencies": {
"@hono/zod-validator": "^0.7.6",
"@modelcontextprotocol/sdk": "^1.29.0",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-router": "^1.167.0",
@@ -161,6 +162,8 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@hono/node-server": ["@hono/node-server@1.19.12", "", { "peerDependencies": { "hono": "^4" } }, "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw=="],
"@hono/zod-validator": ["@hono/zod-validator@0.7.6", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
@@ -173,6 +176,8 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@oxc-project/runtime": ["@oxc-project/runtime@0.115.0", "", {}, "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ=="],
@@ -311,8 +316,14 @@
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
@@ -337,6 +348,8 @@
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
@@ -347,6 +360,12 @@
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"caniuse-lite": ["caniuse-lite@1.0.30001778", "", {}, "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
@@ -365,10 +384,22 @@
"concurrently": ["concurrently@9.2.1", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.8.3", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng=="],
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="],
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
@@ -401,6 +432,8 @@
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
@@ -409,14 +442,26 @@
"drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"electron-to-chromium": ["electron-to-chromium@1.5.313", "", {}, "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
@@ -425,26 +470,54 @@
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"express-rate-limit": ["express-rate-limit@8.3.2", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
"github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
@@ -453,12 +526,22 @@
"goober": ["goober@2.1.18", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hono": ["hono@4.12.8", "", {}, "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
@@ -469,6 +552,10 @@
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
@@ -479,14 +566,24 @@
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"isbot": ["isbot@5.1.36", "", {}, "sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
@@ -519,6 +616,16 @@
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
@@ -531,28 +638,52 @@
"napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="],
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"node-abi": ["node-abi@3.88.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-At6b4UqIEVudaqPsXjmUO1r/N5BUr4yhDGs5PkBE8/oG5+TfLPhFechiskFsnT6Ql0VfUXbalUUCbfXxtj7K+w=="],
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
"qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
@@ -577,26 +708,50 @@
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
"seroval": ["seroval@1.5.1", "", {}, "sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA=="],
"seroval-plugins": ["seroval-plugins@1.5.1", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw=="],
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="],
"simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="],
@@ -607,6 +762,8 @@
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
@@ -633,6 +790,8 @@
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
@@ -641,10 +800,14 @@
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
@@ -653,12 +816,16 @@
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
"vite": ["vite@8.0.0", "", { "dependencies": { "@oxc-project/runtime": "0.115.0", "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.9", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.0.0-alpha.31", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
@@ -673,6 +840,8 @@
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],

642
docs/api.md Normal file
View File

@@ -0,0 +1,642 @@
# API Reference
Base URL: `http://localhost:3000`
**Auth:** GET endpoints are public. POST, PUT, PATCH, and DELETE require authentication via session cookie or `X-API-Key` header. See [authentication.md](authentication.md) for details.
**Prices** are stored and returned in cents (e.g., `2500` = $25.00).
**Weights** are in grams.
**Timestamps** are unix epoch integers.
---
## Table of Contents
- [Health](#health)
- [Items](#items)
- [Categories](#categories)
- [Threads](#threads)
- [Setups](#setups)
- [Images](#images)
- [Settings](#settings)
- [Totals](#totals)
- [Auth](#auth)
---
## Health
### `GET /api/health`
Returns server status. Always public.
**Response:**
```json
{ "status": "ok" }
```
---
## Items
### `GET /api/items`
List all items in the collection.
**Response:**
```json
[
{
"id": 1,
"name": "Revelate Tangle Frame Bag",
"categoryId": 2,
"weightGrams": 185,
"priceCents": 12000,
"notes": "Medium size, fits 2020 Surly Straggler",
"productUrl": "https://revelatedesigns.com/...",
"imageFilename": "1710000000000-uuid.jpg",
"imageSourceUrl": "https://example.com/image.jpg",
"createdAt": 1710000000,
"updatedAt": 1710000000
}
]
```
---
### `GET /api/items/:id`
Get a single item by ID.
**Response:** item object (see above), or `404 { "error": "Item not found" }`.
---
### `POST /api/items`
Create a new item. Auth required.
**Request:**
```json
{
"name": "Revelate Tangle Frame Bag",
"categoryId": 2,
"weightGrams": 185,
"priceCents": 12000,
"notes": "Medium size",
"productUrl": "https://revelatedesigns.com/...",
"imageFilename": "1710000000000-uuid.jpg",
"imageSourceUrl": "https://example.com/image.jpg"
}
```
| Field | Type | Required | Description |
|-----------------|---------|----------|--------------------------------------|
| `name` | string | yes | Item name |
| `categoryId` | integer | yes | ID of an existing category |
| `weightGrams` | number | no | Weight in grams (non-negative) |
| `priceCents` | integer | no | Price in cents (non-negative) |
| `notes` | string | no | Free-text notes |
| `productUrl` | string | no | URL to product page |
| `imageFilename` | string | no | Filename from a prior image upload |
| `imageSourceUrl`| string | no | Original URL the image came from |
**Response:** `201` — created item object.
---
### `PUT /api/items/:id`
Update an existing item. All fields are optional. Auth required.
**Request:** same fields as POST, all optional.
**Response:** updated item object, or `404`.
---
### `DELETE /api/items/:id`
Delete an item and clean up its image file if one exists. Auth required.
**Response:**
```json
{ "success": true }
```
Returns `404` if item not found.
---
## Categories
### `GET /api/categories`
List all categories.
**Response:**
```json
[
{ "id": 1, "name": "Uncategorized", "icon": "package" },
{ "id": 2, "name": "Bags", "icon": "backpack" }
]
```
---
### `POST /api/categories`
Create a new category. Auth required.
**Request:**
```json
{ "name": "Bags", "icon": "backpack" }
```
| Field | Type | Required | Description |
|--------|--------|----------|--------------------------------------|
| `name` | string | yes | Category name |
| `icon` | string | no | Icon name (defaults to `"package"`) |
**Response:** `201` — created category object.
---
### `PUT /api/categories/:id`
Update a category. Auth required.
**Request:**
```json
{ "name": "Carry", "icon": "bag" }
```
**Response:** updated category object, or `404`.
---
### `DELETE /api/categories/:id`
Delete a category. Auth required.
**Response:**
```json
{ "success": true }
```
---
## Threads
Research threads track multiple candidate options for a single gear slot before committing to a purchase.
### `GET /api/threads`
List threads. By default only active (unresolved) threads are returned.
**Query parameters:**
| Parameter | Type | Default | Description |
|-------------------|---------|---------|------------------------------------|
| `includeResolved` | boolean | `false` | Set to `true` to include resolved threads |
**Example:** `GET /api/threads?includeResolved=true`
**Response:**
```json
[
{
"id": 1,
"name": "Handlebar bag",
"categoryId": 2,
"status": "active",
"resolvedCandidateId": null,
"createdAt": 1710000000,
"updatedAt": 1710000000
}
]
```
---
### `POST /api/threads`
Create a new research thread. Auth required.
**Request:**
```json
{ "name": "Handlebar bag", "categoryId": 2 }
```
**Response:** `201` — created thread object.
---
### `GET /api/threads/:id`
Get a thread with all its candidates.
**Response:**
```json
{
"id": 1,
"name": "Handlebar bag",
"categoryId": 2,
"status": "active",
"resolvedCandidateId": null,
"candidates": [
{
"id": 10,
"threadId": 1,
"name": "Revelate Sweetroll",
"categoryId": 2,
"weightGrams": 290,
"priceCents": 13500,
"notes": "16L, fits most drop bars",
"productUrl": "https://revelatedesigns.com/...",
"imageFilename": null,
"imageSourceUrl": null,
"status": "researching",
"pros": "Waterproof, large capacity",
"cons": "Pricey",
"sortOrder": 0,
"createdAt": 1710000000,
"updatedAt": 1710000000
}
]
}
```
Returns `404` if thread not found.
---
### `PUT /api/threads/:id`
Update a thread's name or category. Auth required.
**Request:**
```json
{ "name": "Bar bag", "categoryId": 2 }
```
**Response:** updated thread object, or `404`.
---
### `DELETE /api/threads/:id`
Delete a thread and all its candidates. Cleans up any candidate image files. Auth required.
**Response:**
```json
{ "success": true }
```
---
### `POST /api/threads/:id/candidates`
Add a candidate to a thread. Auth required.
**Request:**
```json
{
"name": "Revelate Sweetroll",
"categoryId": 2,
"weightGrams": 290,
"priceCents": 13500,
"notes": "16L capacity",
"productUrl": "https://revelatedesigns.com/...",
"imageFilename": "1710000000000-uuid.jpg",
"imageSourceUrl": "https://example.com/image.jpg",
"status": "researching",
"pros": "Waterproof, large capacity",
"cons": "Expensive"
}
```
| Field | Type | Required | Description |
|-----------------|---------|----------|----------------------------------------------------|
| `name` | string | yes | Candidate name |
| `categoryId` | integer | yes | Category ID |
| `weightGrams` | number | no | Weight in grams |
| `priceCents` | integer | no | Price in cents |
| `notes` | string | no | Notes |
| `productUrl` | string | no | Product URL |
| `imageFilename` | string | no | Filename from a prior image upload |
| `imageSourceUrl`| string | no | Original image URL |
| `status` | string | no | `"researching"`, `"ordered"`, or `"arrived"` |
| `pros` | string | no | Pros of this option |
| `cons` | string | no | Cons of this option |
**Response:** `201` — created candidate object. Returns `404` if thread not found.
---
### `PUT /api/threads/:threadId/candidates/:candidateId`
Update a candidate. All fields optional. Auth required.
**Request:** same fields as POST, all optional.
**Response:** updated candidate object, or `404`.
---
### `DELETE /api/threads/:threadId/candidates/:candidateId`
Delete a candidate and clean up its image. Auth required.
**Response:**
```json
{ "success": true }
```
---
### `PATCH /api/threads/:id/candidates/reorder`
Reorder candidates within a thread. Auth required.
**Request:**
```json
{ "orderedIds": [12, 10, 11] }
```
`orderedIds` is an array of candidate IDs in the desired display order. All IDs must belong to the thread.
**Response:**
```json
{ "success": true }
```
Returns `400` with an error message if validation fails.
---
### `POST /api/threads/:id/resolve`
Resolve a thread by selecting the winning candidate. Auth required.
The winning candidate's data is copied into a new item in the collection. The thread status is set to `"resolved"` and `resolvedCandidateId` is set.
**Request:**
```json
{ "candidateId": 10 }
```
**Response:**
```json
{
"success": true,
"item": {
"id": 42,
"name": "Revelate Sweetroll",
"categoryId": 2,
"weightGrams": 290,
"priceCents": 13500
}
}
```
Returns `400` if the candidate does not belong to the thread or another error occurs.
---
## Setups
Setups are named collections of items (e.g., "Bikepacking weekend", "Commute loadout").
### `GET /api/setups`
List all setups with item counts and weight/cost totals.
**Response:**
```json
[
{
"id": 1,
"name": "Bikepacking weekend",
"itemCount": 12,
"totalWeightGrams": 4800,
"totalPriceCents": 285000,
"createdAt": 1710000000,
"updatedAt": 1710000000
}
]
```
---
### `POST /api/setups`
Create a new setup. Auth required.
**Request:**
```json
{ "name": "Bikepacking weekend" }
```
**Response:** `201` — created setup object.
---
### `GET /api/setups/:id`
Get a setup with its full item list.
**Response:**
```json
{
"id": 1,
"name": "Bikepacking weekend",
"items": [
{
"id": 1,
"name": "Revelate Tangle Frame Bag",
"weightGrams": 185,
"priceCents": 12000,
"categoryId": 2,
"classification": "base"
}
],
"totalWeightGrams": 185,
"totalPriceCents": 12000
}
```
Returns `404` if setup not found.
---
### `PUT /api/setups/:id`
Update a setup's name. Auth required.
**Request:**
```json
{ "name": "Bikepacking overnighter" }
```
**Response:** updated setup object, or `404`.
---
### `PUT /api/setups/:id/items`
Replace all items in a setup atomically. Deletes all existing setup-item associations and re-inserts with the provided IDs. Auth required.
**Request:**
```json
{ "itemIds": [1, 5, 12, 17] }
```
Pass an empty array to remove all items from the setup.
**Response:**
```json
{ "success": true }
```
---
### `DELETE /api/setups/:id`
Delete a setup. Does not delete the items themselves. Auth required.
**Response:**
```json
{ "success": true }
```
---
## Images
### `POST /api/images`
Upload an image file. Auth required.
**Request:** `multipart/form-data` with an `image` field.
```
POST /api/images
Content-Type: multipart/form-data; boundary=...
--boundary
Content-Disposition: form-data; name="image"; filename="bag.jpg"
Content-Type: image/jpeg
<binary data>
--boundary--
```
Constraints:
- Accepted types: `image/jpeg`, `image/png`, `image/webp`
- Maximum size: 5MB
- Files are saved to `./uploads/` with a UUID-based filename
**Response:** `201`
```json
{ "filename": "1710000000000-550e8400-e29b-41d4-a716-446655440000.jpg" }
```
Use the returned `filename` as `imageFilename` when creating or updating items or candidates.
---
### `POST /api/images/from-url`
Fetch an image from a remote URL and save it locally. Auth required.
**Request:**
```json
{ "url": "https://example.com/product-image.jpg" }
```
**Response:** `201`
```json
{
"filename": "1710000000000-uuid.jpg",
"sourceUrl": "https://example.com/product-image.jpg"
}
```
Returns `400` for invalid URLs, unsupported content types, files over 5MB, or non-200 HTTP responses.
---
## Settings
Key-value store for application settings.
### `GET /api/settings/:key`
Get a setting by key. Public.
**Response:**
```json
{ "key": "collectionName", "value": "My Bikepacking Gear" }
```
Returns `404` if the key does not exist.
---
### `PUT /api/settings/:key`
Create or update a setting. Auth required.
**Request:**
```json
{ "value": "My Bikepacking Gear" }
```
**Response:** updated setting object.
---
## Totals
### `GET /api/totals`
Global and per-category weight and cost totals. Computed from current item data. Public.
**Response:**
```json
{
"global": {
"totalWeightGrams": 12500,
"totalPriceCents": 450000,
"itemCount": 34
},
"categories": [
{
"categoryId": 2,
"name": "Bags",
"icon": "backpack",
"totalWeightGrams": 1200,
"totalPriceCents": 78000,
"itemCount": 5
}
]
}
```
---
## Auth
Authentication endpoints are documented in [authentication.md](authentication.md).
| Method | Path | Auth | Description |
|--------|-------------------------|----------|---------------------------|
| GET | `/api/auth/me` | public | Current session state |
| POST | `/api/auth/setup` | public | Create first admin user |
| POST | `/api/auth/login` | public | Log in, receive cookie |
| POST | `/api/auth/logout` | public | Clear session |
| PUT | `/api/auth/password` | session | Change password |
| GET | `/api/auth/keys` | required | List API keys |
| POST | `/api/auth/keys` | required | Create API key |
| DELETE | `/api/auth/keys/:id` | required | Revoke API key |

281
docs/authentication.md Normal file
View File

@@ -0,0 +1,281 @@
# Authentication
GearBox uses a public-read, authenticated-write model. All GET endpoints are publicly accessible with no credentials required. Any request that modifies data (POST, PUT, PATCH, DELETE) requires authentication.
This is a single-user app. There is exactly one admin account.
## Table of Contents
- [First-Time Setup](#first-time-setup)
- [Web UI Authentication](#web-ui-authentication)
- [API Keys](#api-keys)
- [Auth Middleware Behavior](#auth-middleware-behavior)
- [Auth API Reference](#auth-api-reference)
- [Frontend Behavior](#frontend-behavior)
---
## First-Time Setup
When no users exist, all write endpoints return `403` with `{ "error": "setup_required" }`. To create the admin account, visit `/login` in the browser and complete the setup form, or call the setup endpoint directly:
```http
POST /api/auth/setup
Content-Type: application/json
{
"username": "admin",
"password": "yourpassword"
}
```
Requirements:
- `username`: any non-empty string
- `password`: minimum 6 characters
This endpoint only works when no users exist. Subsequent calls return `403 { "error": "Setup already completed" }`.
On success, a session cookie is set and `201` is returned:
```json
{ "username": "admin" }
```
---
## Web UI Authentication
Sessions use an `httpOnly` cookie named `gearbox_session`.
| Property | Value |
|------------|--------------------|
| Cookie name | `gearbox_session` |
| httpOnly | true |
| sameSite | Lax |
| path | / |
| Max age | 30 days |
The session expiry is **automatically refreshed** on each authenticated request. As long as the app is used at least once every 30 days, the session stays active.
Passwords are hashed with **argon2** via `Bun.password`.
### Changing Your Password
Requires an active session cookie.
```http
PUT /api/auth/password
Content-Type: application/json
{
"currentPassword": "oldpassword",
"newPassword": "newpassword"
}
```
---
## API Keys
API keys are intended for programmatic access (scripts, MCP clients, integrations). They are managed under **Settings > API Keys** in the web UI, or via the API endpoints listed below.
### Key behavior
- Keys are shown **once** at creation time. Store them securely.
- Keys are stored as an argon2 hash. Only the 8-character prefix is stored in plaintext for display and lookup purposes.
- Pass the key via the `X-API-Key` request header on any write request.
```http
POST /api/items
X-API-Key: gbk_a1b2c3d4...
Content-Type: application/json
{ "name": "Revelate Tangle", "categoryId": 2 }
```
If both a session cookie and an `X-API-Key` header are present, the API key is checked first.
---
## Auth Middleware Behavior
The middleware applied to `/api/*` (excluding `/api/auth/*`) follows these rules:
1. `GET` requests — always allowed, no auth check.
2. No users exist — returns `403 { "error": "setup_required" }`.
3. `X-API-Key` header present — verified against stored hashes; `401` on failure.
4. `gearbox_session` cookie present — verified against sessions table; refreshed on success; `401` on failure.
5. Neither credential present — returns `401 { "error": "Authentication required" }`.
The `/api/auth/*` routes handle their own auth logic and are excluded from the global middleware.
---
## Auth API Reference
### `GET /api/auth/me`
Returns the current session state. Always public.
**Response when logged in:**
```json
{
"user": { "id": 1 },
"setupRequired": false
}
```
**Response when logged out, setup complete:**
```json
{
"user": null,
"setupRequired": false
}
```
**Response when no users exist:**
```json
{
"user": null,
"setupRequired": true
}
```
---
### `POST /api/auth/setup`
Create the first admin account. Only works when no users exist.
**Request:**
```json
{
"username": "admin",
"password": "yourpassword"
}
```
**Response:** `201`
```json
{ "username": "admin" }
```
Sets `gearbox_session` cookie.
---
### `POST /api/auth/login`
Log in with username and password.
**Request:**
```json
{
"username": "admin",
"password": "yourpassword"
}
```
**Response:** `200`
```json
{ "username": "admin" }
```
Sets `gearbox_session` cookie. Returns `401` on invalid credentials.
---
### `POST /api/auth/logout`
Clear the current session. No request body needed.
**Response:**
```json
{ "ok": true }
```
Clears the `gearbox_session` cookie and deletes the session from the database.
---
### `PUT /api/auth/password`
Change the admin password. Requires an active session cookie (not API key).
**Request:**
```json
{
"currentPassword": "oldpassword",
"newPassword": "newpassword"
}
```
**Response:**
```json
{ "ok": true }
```
Returns `401` if `currentPassword` is incorrect.
---
### `GET /api/auth/keys`
List all API keys. Returns name, prefix, and creation timestamp — never the full key.
Requires auth.
**Response:**
```json
[
{
"id": 1,
"name": "Claude Code",
"prefix": "gbk_a1b2",
"createdAt": "2025-03-01T10:00:00.000Z"
}
]
```
---
### `POST /api/auth/keys`
Create a new API key. The full key is returned **once** and cannot be retrieved again.
Requires auth.
**Request:**
```json
{ "name": "Claude Code" }
```
**Response:** `201`
```json
{
"id": 1,
"name": "Claude Code",
"key": "gbk_a1b2c3d4e5f6g7h8i9j0...",
"prefix": "gbk_a1b2"
}
```
---
### `DELETE /api/auth/keys/:id`
Revoke an API key by ID. Requires auth.
**Response:**
```json
{ "ok": true }
```
---
## Frontend Behavior
- A login button is shown in the top-right corner of the UI (Gitea-style).
- The floating action button (FAB) for adding items is hidden when not logged in.
- Edit and delete actions on items, threads, and setups require auth. Unauthenticated users see read-only views.
- When `setupRequired` is true, the UI redirects to the setup flow.

253
docs/mcp-server.md Normal file
View File

@@ -0,0 +1,253 @@
# MCP Server
GearBox includes a built-in MCP (Model Context Protocol) server that exposes your gear collection to AI assistants. It runs inside the Hono process — no separate service is needed.
The MCP server implements the [Streamable HTTP transport](https://modelcontextprotocol.io/specification) from `@modelcontextprotocol/sdk` and is mounted at `/mcp`.
---
## Table of Contents
- [Enabling and Disabling](#enabling-and-disabling)
- [Authentication](#authentication)
- [Connecting an AI Client](#connecting-an-ai-client)
- [Claude Code](#claude-code)
- [Claude Desktop](#claude-desktop)
- [Tools](#tools)
- [Resources](#resources)
- [Recommended Workflow](#recommended-workflow)
- [Example Session](#example-session)
- [Implementation Structure](#implementation-structure)
---
## Enabling and Disabling
The MCP server is **enabled by default**. To disable it, set the environment variable:
```bash
GEARBOX_MCP=false bun run dev:server
```
Or in your environment file:
```bash
GEARBOX_MCP=false
```
When disabled, the `/mcp` route is not registered and any requests to it return 404.
---
## Authentication
The MCP server requires an API key when the app has been set up (i.e., at least one user exists). Pass the key via the `X-API-Key` header in your MCP client configuration.
To create an API key:
1. Log in to the web UI.
2. Go to **Settings > API Keys**.
3. Click **New Key**, give it a name, and copy the full key shown.
The full key is only shown once. Store it in your client configuration immediately.
See [authentication.md](authentication.md) for full API key documentation.
---
## Connecting an AI Client
### Claude Code
Add the MCP server to `.claude/settings.json` in your project or globally at `~/.claude/settings.json`:
```json
{
"mcpServers": {
"gearbox": {
"type": "streamable-http",
"url": "http://localhost:3000/mcp",
"headers": {
"X-API-Key": "your-api-key-here"
}
}
}
}
```
### Claude Desktop
Add the server to `claude_desktop_config.json`:
```json
{
"mcpServers": {
"gearbox": {
"type": "streamable-http",
"url": "http://localhost:3000/mcp",
"headers": {
"X-API-Key": "your-api-key-here"
}
}
}
}
```
On macOS, the config file is at `~/Library/Application Support/Claude/claude_desktop_config.json`.
After updating the config, restart Claude Desktop (or reload the window in Claude Code) for the server to connect.
---
## Tools
The MCP server exposes 18 tools across five categories.
### Items
| Tool | Description |
|----------------|------------------------------------------------------------------------------------------------------------|
| `list_items` | List all items in the gear collection, optionally filtered by `categoryId`. |
| `get_item` | Get a single item by ID with all details. |
| `create_item` | Add a new item directly to the collection. Use for items already decided on; use `create_thread` for research. |
| `update_item` | Update an existing item's fields. |
| `delete_item` | Delete an item from the collection by ID. |
### Categories
| Tool | Description |
|-------------------|--------------------------------------|
| `list_categories` | List all gear categories. |
| `create_category` | Create a new gear category. |
### Threads
| Tool | Description |
|------------------|------------------------------------------------------------------------------------------------------|
| `list_threads` | List research threads. Pass `includeResolved: true` to include resolved threads. |
| `get_thread` | Get a thread with all its candidates for detailed comparison. |
| `create_thread` | Start a new research thread for a gear slot. Preferred entry point for gear research. |
| `resolve_thread` | Resolve a thread by picking the winning candidate. Automatically creates a new item in the collection. |
### Candidates
| Tool | Description |
|--------------------|-----------------------------------------------------------------------|
| `add_candidate` | Add a candidate option to a research thread for comparison. |
| `update_candidate` | Update a candidate's details (name, price, pros, cons, status, etc.).|
| `remove_candidate` | Remove a candidate from a research thread. |
### Setups
| Tool | Description |
|-----------------|-------------------------------------------------------------------------------------------------------|
| `list_setups` | List all gear setups with item counts and weight/cost totals. |
| `get_setup` | Get a setup with all its items and details. |
| `create_setup` | Create a new gear setup (e.g., "Bikepacking weekend"). |
| `update_setup` | Update a setup's name and/or replace its item list by passing `itemIds`. |
### Images
| Tool | Description |
|-------------------------|--------------------------------------------------------------------------------------------------|
| `upload_image_from_url` | Fetch an image from a URL and save it locally. Returns a `filename` to use with items or candidates. |
---
## Resources
### `gearbox://collection/summary`
Returns a JSON overview of the entire gear collection, including:
- Total item count, weight, and cost
- Per-category breakdowns
- Active research thread count
Read this resource first to orient yourself before running queries or making changes.
---
## Recommended Workflow
Tool descriptions are written to guide AI assistants toward the research thread workflow rather than creating items directly. The intended flow for evaluating a new piece of gear is:
1. **`create_thread`** — open a research thread for the gear slot (e.g., "Handlebar bag")
2. **`add_candidate`** — add options with prices, weights, pros, and cons
3. **`get_thread`** — review all candidates side by side
4. **`resolve_thread`** — pick the winner; it becomes a new item in the collection automatically
Use `create_item` directly only when you already know exactly what you're adding and no research is needed.
---
## Example Session
The following illustrates a typical assistant interaction via the MCP server.
**User:** "I need a new handlebar bag. Can you research some options and add them?"
**Assistant actions:**
```
1. list_categories
-> finds "Bags" category (id: 2)
2. create_thread { name: "Handlebar bag", categoryId: 2 }
-> thread id: 7
3. upload_image_from_url { url: "https://revelatedesigns.com/sweetroll.jpg" }
-> filename: "1710000000000-uuid.jpg"
4. add_candidate {
threadId: 7,
name: "Revelate Sweetroll",
categoryId: 2,
weightGrams: 290,
priceCents: 13500,
productUrl: "https://revelatedesigns.com/...",
imageFilename: "1710000000000-uuid.jpg",
pros: "Waterproof, 16L, fits most drop bars",
cons: "Expensive, bulky profile"
}
5. add_candidate {
threadId: 7,
name: "Apidura Backcountry Handlebar Pack",
categoryId: 2,
weightGrams: 230,
priceCents: 11000,
pros: "Lighter, packable, good attachment system",
cons: "Smaller capacity"
}
6. add_candidate { ... }
7. get_thread { id: 7 }
-> presents comparison to user
8. resolve_thread { threadId: 7, candidateId: 14 }
-> Revelate Sweetroll added to collection as item id: 42
```
---
## Implementation Structure
The MCP server source lives under `src/server/mcp/`:
```
src/server/mcp/
index.ts # Server factory, transport handling, auth middleware, Hono route registration
tools/
items.ts # list_items, get_item, create_item, update_item, delete_item
categories.ts # list_categories, create_category
threads.ts # list_threads, get_thread, create_thread, resolve_thread,
# add_candidate, update_candidate, remove_candidate
setups.ts # list_setups, get_setup, create_setup, update_setup
images.ts # upload_image_from_url
resources/
collection.ts # gearbox://collection/summary resource handler
```
Each tool file exports a `*ToolDefinitions` array (name, description, inputSchema) and a `register*Tools(db)` factory function that returns handler implementations. The server in `index.ts` iterates over definitions and registers each handler with the MCP `McpServer` instance.
Session management uses the `WebStandardStreamableHTTPServerTransport` with UUID-based session IDs tracked in an in-memory `Map`. Sessions are cleaned up when the transport closes.

View File

@@ -0,0 +1,2 @@
ALTER TABLE `items` ADD `image_source_url` text;--> statement-breakpoint
ALTER TABLE `thread_candidates` ADD `image_source_url` text;

View File

@@ -0,0 +1,23 @@
CREATE TABLE `api_keys` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`key_hash` text NOT NULL,
`key_prefix` text NOT NULL,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `sessions` (
`id` text PRIMARY KEY NOT NULL,
`user_id` integer NOT NULL,
`expires_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `users` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`username` text NOT NULL,
`password_hash` text NOT NULL,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);

View File

@@ -0,0 +1,519 @@
{
"version": "6",
"dialect": "sqlite",
"id": "e26d6e96-f2cd-4b76-ad4e-58da0af6f3c0",
"prevId": "297e86db-c777-4432-950e-b0129dedb2dc",
"tables": {
"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
},
"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": {}
},
"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": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,655 @@
{
"version": "6",
"dialect": "sqlite",
"id": "738e67c5-ebad-46c1-9261-6ab60ec4bdb1",
"prevId": "e26d6e96-f2cd-4b76-ad4e-58da0af6f3c0",
"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
},
"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": {}
},
"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": {}
}
}

View File

@@ -43,6 +43,20 @@
"when": 1773696058992,
"tag": "0005_clear_micromax",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1775214741464,
"tag": "0006_hard_the_professor",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1775215076284,
"tag": "0007_icy_prodigy",
"breakpoints": true
}
]
}

View File

@@ -33,6 +33,7 @@
},
"dependencies": {
"@hono/zod-validator": "^0.7.6",
"@modelcontextprotocol/sdk": "^1.29.0",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-router": "^1.167.0",

View File

@@ -1,4 +1,5 @@
import { Link } from "@tanstack/react-router";
import { useAuth, useLogout } from "../hooks/useAuth";
import { useUpdateSetting } from "../hooks/useSettings";
import { useTotals } from "../hooks/useTotals";
import { useWeightUnit } from "../hooks/useWeightUnit";
@@ -19,6 +20,9 @@ export function TotalsBar({
linkTo,
}: TotalsBarProps) {
const { data } = useTotals();
const { data: auth } = useAuth();
const logout = useLogout();
const isAuthenticated = !!auth?.user;
const unit = useWeightUnit();
const updateSetting = useUpdateSetting();
@@ -100,6 +104,24 @@ export function TotalsBar({
))}
</div>
)}
<div className="flex items-center gap-2 ml-auto">
{isAuthenticated ? (
<button
type="button"
onClick={() => logout.mutate()}
className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
>
Sign out
</button>
) : (
<Link
to="/login"
className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
>
Sign in
</Link>
)}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,97 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
interface AuthState {
user: { id: number } | null;
setupRequired: boolean;
}
export function useAuth() {
return useQuery({
queryKey: ["auth"],
queryFn: () => apiGet<AuthState>("/api/auth/me"),
staleTime: 5 * 60 * 1000,
});
}
export function useLogin() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { username: string; password: string }) =>
apiPost<{ username: string }>("/api/auth/login", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["auth"] });
},
});
}
export function useLogout() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => apiPost<{ success: boolean }>("/api/auth/logout", {}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["auth"] });
},
});
}
export function useSetup() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { username: string; password: string }) =>
apiPost<{ username: string }>("/api/auth/setup", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["auth"] });
},
});
}
export function useChangePassword() {
return useMutation({
mutationFn: (data: { currentPassword: string; newPassword: string }) =>
apiPut<{ success: boolean }>("/api/auth/password", data),
});
}
interface ApiKeyListItem {
id: number;
name: string;
keyPrefix: string;
createdAt: string;
}
interface ApiKeyResponse {
id: number;
name: string;
key: string;
prefix: string;
}
export function useApiKeys() {
return useQuery({
queryKey: ["apiKeys"],
queryFn: () => apiGet<ApiKeyListItem[]>("/api/auth/keys"),
});
}
export function useCreateApiKey() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { name: string }) =>
apiPost<ApiKeyResponse>("/api/auth/keys", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["apiKeys"] });
},
});
}
export function useDeleteApiKey() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) =>
apiDelete<{ success: boolean }>(`/api/auth/keys/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["apiKeys"] });
},
});
}

View File

@@ -10,6 +10,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as LoginRouteImport } from './routes/login'
import { Route as IndexRouteImport } from './routes/index'
import { Route as CollectionIndexRouteImport } from './routes/collection/index'
import { Route as ThreadsThreadIdRouteImport } from './routes/threads/$threadId'
@@ -20,6 +21,11 @@ const SettingsRoute = SettingsRouteImport.update({
path: '/settings',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
@@ -43,6 +49,7 @@ const SetupsSetupIdRoute = SetupsSetupIdRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/login': typeof LoginRoute
'/settings': typeof SettingsRoute
'/setups/$setupId': typeof SetupsSetupIdRoute
'/threads/$threadId': typeof ThreadsThreadIdRoute
@@ -50,6 +57,7 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/login': typeof LoginRoute
'/settings': typeof SettingsRoute
'/setups/$setupId': typeof SetupsSetupIdRoute
'/threads/$threadId': typeof ThreadsThreadIdRoute
@@ -58,6 +66,7 @@ export interface FileRoutesByTo {
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/login': typeof LoginRoute
'/settings': typeof SettingsRoute
'/setups/$setupId': typeof SetupsSetupIdRoute
'/threads/$threadId': typeof ThreadsThreadIdRoute
@@ -67,6 +76,7 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/login'
| '/settings'
| '/setups/$setupId'
| '/threads/$threadId'
@@ -74,6 +84,7 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/login'
| '/settings'
| '/setups/$setupId'
| '/threads/$threadId'
@@ -81,6 +92,7 @@ export interface FileRouteTypes {
id:
| '__root__'
| '/'
| '/login'
| '/settings'
| '/setups/$setupId'
| '/threads/$threadId'
@@ -89,6 +101,7 @@ export interface FileRouteTypes {
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
LoginRoute: typeof LoginRoute
SettingsRoute: typeof SettingsRoute
SetupsSetupIdRoute: typeof SetupsSetupIdRoute
ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute
@@ -104,6 +117,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
@@ -137,6 +157,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
LoginRoute: LoginRoute,
SettingsRoute: SettingsRoute,
SetupsSetupIdRoute: SetupsSetupIdRoute,
ThreadsThreadIdRoute: ThreadsThreadIdRoute,

View File

@@ -13,6 +13,7 @@ import { ItemForm } from "../components/ItemForm";
import { OnboardingWizard } from "../components/OnboardingWizard";
import { SlideOutPanel } from "../components/SlideOutPanel";
import { TotalsBar } from "../components/TotalsBar";
import { useAuth } from "../hooks/useAuth";
import { useDeleteCandidate } from "../hooks/useCandidates";
import { useOnboardingComplete } from "../hooks/useSettings";
import { useResolveThread, useThread } from "../hooks/useThreads";
@@ -24,6 +25,8 @@ export const Route = createRootRoute({
function RootLayout() {
const navigate = useNavigate();
const { data: auth } = useAuth();
const isAuthenticated = !!auth?.user;
// Item panel state
const panelMode = useUIStore((s) => s.panelMode);
@@ -54,8 +57,9 @@ function RootLayout() {
useOnboardingComplete();
const [wizardDismissed, setWizardDismissed] = useState(false);
// Don't show onboarding wizard until user has created an account
const showWizard =
!onboardingLoading && onboardingComplete !== "true" && !wizardDismissed;
!onboardingLoading && onboardingComplete !== "true" && !wizardDismissed && isAuthenticated;
const isItemPanelOpen = panelMode !== "closed";
const isCandidatePanelOpen = candidatePanelMode !== "closed";
@@ -175,7 +179,7 @@ function RootLayout() {
)}
{/* Floating Add Button - only on collection gear tab */}
{showFab && (
{showFab && isAuthenticated && (
<button
type="button"
onClick={openAddPanel}

104
src/client/routes/login.tsx Normal file
View File

@@ -0,0 +1,104 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { useAuth, useLogin, useSetup } from "../hooks/useAuth";
export const Route = createFileRoute("/login")({
component: LoginPage,
});
function LoginPage() {
const navigate = useNavigate();
const { data: auth } = useAuth();
const login = useLogin();
const setup = useSetup();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const isSetup = auth?.setupRequired ?? false;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
try {
if (isSetup) {
await setup.mutateAsync({ username, password });
} else {
await login.mutateAsync({ username, password });
}
navigate({ to: "/" });
} catch (err) {
setError((err as Error).message);
}
}
const isPending = login.isPending || setup.isPending;
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
<div className="w-full max-w-sm">
<h1 className="text-xl font-semibold text-gray-900 text-center mb-6">
{isSetup ? "Create Account" : "Sign In"}
</h1>
<form
onSubmit={handleSubmit}
className="bg-white rounded-xl border border-gray-100 p-6 space-y-4"
>
{isSetup && (
<p className="text-sm text-gray-500">
Create your admin account to manage your gear collection.
</p>
)}
<div>
<label
htmlFor="username"
className="block text-sm font-medium text-gray-700 mb-1"
>
Username
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-1"
>
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={isSetup ? 6 : undefined}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200"
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={isPending}
className="w-full py-2 px-4 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
>
{isPending ? "..." : isSetup ? "Create Account" : "Sign In"}
</button>
</form>
</div>
</div>
);
}

View File

@@ -1,4 +1,12 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useState } from "react";
import {
useApiKeys,
useAuth,
useChangePassword,
useCreateApiKey,
useDeleteApiKey,
} from "../hooks/useAuth";
import { useCurrency } from "../hooks/useCurrency";
import { useUpdateSetting } from "../hooks/useSettings";
import { useWeightUnit } from "../hooks/useWeightUnit";
@@ -18,10 +26,157 @@ export const Route = createFileRoute("/settings")({
component: SettingsPage,
});
function ChangePasswordSection() {
const changePassword = useChangePassword();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [message, setMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setMessage(null);
try {
await changePassword.mutateAsync({ currentPassword, newPassword });
setMessage({ type: "success", text: "Password changed" });
setCurrentPassword("");
setNewPassword("");
} catch (err) {
setMessage({ type: "error", text: (err as Error).message });
}
}
return (
<form onSubmit={handleSubmit} className="space-y-3">
<h3 className="text-sm font-medium text-gray-900">Change Password</h3>
<input
type="password"
placeholder="Current password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200"
/>
<input
type="password"
placeholder="New password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={6}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200"
/>
{message && (
<p
className={`text-sm ${message.type === "success" ? "text-green-600" : "text-red-600"}`}
>
{message.text}
</p>
)}
<button
type="submit"
disabled={changePassword.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
>
{changePassword.isPending ? "..." : "Change Password"}
</button>
</form>
);
}
function ApiKeySection() {
const { data: keys } = useApiKeys();
const createKey = useCreateApiKey();
const deleteKey = useDeleteApiKey();
const [name, setName] = useState("");
const [newKey, setNewKey] = useState<string | null>(null);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
const result = await createKey.mutateAsync({ name });
setNewKey(result.key);
setName("");
}
return (
<div className="space-y-3">
<h3 className="text-sm font-medium text-gray-900">API Keys</h3>
<p className="text-xs text-gray-500">
API keys allow programmatic access to GearBox (e.g., from Claude Desktop
or scripts).
</p>
{newKey && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-xs font-medium text-amber-800 mb-1">
Copy this key now it won't be shown again:
</p>
<code className="text-xs text-amber-900 break-all select-all">
{newKey}
</code>
<button
type="button"
onClick={() => setNewKey(null)}
className="mt-2 block text-xs text-amber-700 hover:text-amber-900"
>
Dismiss
</button>
</div>
)}
<form onSubmit={handleCreate} className="flex gap-2">
<input
type="text"
placeholder="Key name (e.g., claude-desktop)"
value={name}
onChange={(e) => setName(e.target.value)}
required
className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200"
/>
<button
type="submit"
disabled={createKey.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
>
Create
</button>
</form>
{keys && keys.length > 0 && (
<div className="space-y-2">
{keys.map((key) => (
<div
key={key.id}
className="flex items-center justify-between py-2 px-3 bg-gray-50 rounded-lg"
>
<div>
<span className="text-sm text-gray-900">{key.name}</span>
<span className="text-xs text-gray-400 ml-2">
{key.keyPrefix}...
</span>
</div>
<button
type="button"
onClick={() => deleteKey.mutate(key.id)}
className="text-xs text-red-500 hover:text-red-700"
>
Revoke
</button>
</div>
))}
</div>
)}
</div>
);
}
function SettingsPage() {
const unit = useWeightUnit();
const currency = useCurrency();
const updateSetting = useUpdateSetting();
const { data: auth } = useAuth();
return (
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
@@ -99,6 +254,14 @@ function SettingsPage() {
</div>
</div>
</div>
{auth?.user && (
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6 mt-4">
<ChangePasswordSection />
<div className="border-t border-gray-100" />
<ApiKeySection />
</div>
)}
</div>
);
}

View File

@@ -20,6 +20,7 @@ export const items = sqliteTable("items", {
notes: text("notes"),
productUrl: text("product_url"),
imageFilename: text("image_filename"),
imageSourceUrl: text("image_source_url"),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
@@ -58,6 +59,7 @@ export const threadCandidates = sqliteTable("thread_candidates", {
notes: text("notes"),
productUrl: text("product_url"),
imageFilename: text("image_filename"),
imageSourceUrl: text("image_source_url"),
status: text("status").notNull().default("researching"),
pros: text("pros"),
cons: text("cons"),
@@ -96,3 +98,30 @@ export const settings = sqliteTable("settings", {
key: text("key").primaryKey(),
value: text("value").notNull(),
});
export const users = sqliteTable("users", {
id: integer("id").primaryKey({ autoIncrement: true }),
username: text("username").notNull().unique(),
passwordHash: text("password_hash").notNull(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
});
export const sessions = sqliteTable("sessions", {
id: text("id").primaryKey(),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
});
export const apiKeys = sqliteTable("api_keys", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
keyHash: text("key_hash").notNull(),
keyPrefix: text("key_prefix").notNull(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
});

View File

@@ -1,6 +1,9 @@
import { Hono } from "hono";
import { serveStatic } from "hono/bun";
import { seedDefaults } from "../db/seed.ts";
import { mcpRoutes } from "./mcp/index.ts";
import { requireAuth } from "./middleware/auth.ts";
import { authRoutes } from "./routes/auth.ts";
import { categoryRoutes } from "./routes/categories.ts";
import { imageRoutes } from "./routes/images.ts";
import { itemRoutes } from "./routes/items.ts";
@@ -19,7 +22,18 @@ app.get("/api/health", (c) => {
return c.json({ status: "ok" });
});
// Auth middleware for write operations (POST/PUT/DELETE) on non-auth routes
app.use("/api/*", async (c, next) => {
// Skip auth routes — they handle their own auth
if (c.req.path.startsWith("/api/auth")) return next();
// Skip GET requests — read is public
if (c.req.method === "GET") return next();
// All other methods require auth
return requireAuth(c, next);
});
// API routes
app.route("/api/auth", authRoutes);
app.route("/api/items", itemRoutes);
app.route("/api/categories", categoryRoutes);
app.route("/api/totals", totalRoutes);
@@ -28,6 +42,11 @@ app.route("/api/settings", settingsRoutes);
app.route("/api/threads", threadRoutes);
app.route("/api/setups", setupRoutes);
// MCP server (conditionally mounted)
if (process.env.GEARBOX_MCP !== "false") {
app.route("/mcp", mcpRoutes);
}
// Serve uploaded images
app.use("/uploads/*", serveStatic({ root: "./" }));

174
src/server/mcp/index.ts Normal file
View File

@@ -0,0 +1,174 @@
import { randomUUID } from "node:crypto";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import { Hono } from "hono";
import { db as prodDb } from "@/db/index.ts";
import { getUserCount, verifyApiKey } from "../services/auth.service.ts";
import { getCollectionSummary } from "./resources/collection.ts";
import {
categoryToolDefinitions,
registerCategoryTools,
} from "./tools/categories.ts";
import { imageToolDefinitions, registerImageTools } from "./tools/images.ts";
import { itemToolDefinitions, registerItemTools } from "./tools/items.ts";
import { registerSetupTools, setupToolDefinitions } from "./tools/setups.ts";
import { registerThreadTools, threadToolDefinitions } from "./tools/threads.ts";
type Db = typeof prodDb;
function createMcpServer(db: Db): McpServer {
const server = new McpServer({ name: "GearBox", version: "1.0.0" });
// Register item tools
const itemHandlers = registerItemTools(db);
for (const def of itemToolDefinitions) {
const handler = itemHandlers[def.name as keyof typeof itemHandlers];
server.tool(def.name, def.description, def.inputSchema, handler);
}
// Register category tools
const categoryHandlers = registerCategoryTools(db);
for (const def of categoryToolDefinitions) {
const handler = categoryHandlers[def.name as keyof typeof categoryHandlers];
server.tool(def.name, def.description, def.inputSchema, handler);
}
// Register thread tools
const threadHandlers = registerThreadTools(db);
for (const def of threadToolDefinitions) {
const handler = threadHandlers[def.name as keyof typeof threadHandlers];
server.tool(def.name, def.description, def.inputSchema, handler);
}
// Register setup tools
const setupHandlers = registerSetupTools(db);
for (const def of setupToolDefinitions) {
const handler = setupHandlers[def.name as keyof typeof setupHandlers];
server.tool(def.name, def.description, def.inputSchema, handler);
}
// Register image tools
const imageHandlers = registerImageTools();
for (const def of imageToolDefinitions) {
const handler = imageHandlers[def.name as keyof typeof imageHandlers];
server.tool(def.name, def.description, def.inputSchema, handler);
}
// Register collection summary resource
server.resource(
"collection-summary",
"gearbox://collection/summary",
{
description:
"Overview of the entire gear collection including totals, categories, and active research threads.",
mimeType: "application/json",
},
async () => {
const summary = getCollectionSummary(db);
return {
contents: [
{
uri: "gearbox://collection/summary",
mimeType: "application/json",
text: JSON.stringify(summary, null, 2),
},
],
};
},
);
return server;
}
// Store active transports by session ID
const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();
export const mcpRoutes = new Hono();
// Auth middleware for all MCP requests
mcpRoutes.use("/*", async (c, next) => {
const db = c.get("db") ?? prodDb;
const apiKey = c.req.header("X-API-Key");
// Require API key when auth is configured (users exist)
if (getUserCount(db) > 0) {
if (!apiKey) {
return c.json({ error: "API key required" }, 401);
}
const valid = await verifyApiKey(db, apiKey);
if (!valid) {
return c.json({ error: "Invalid API key" }, 401);
}
}
return next();
});
mcpRoutes.post("/", async (c) => {
const db = c.get("db") ?? prodDb;
// Check for existing session
const sessionId = c.req.header("mcp-session-id");
if (sessionId) {
const transport = transports.get(sessionId);
if (!transport) {
return c.json({ error: "Session not found" }, 404);
}
const response = await transport.handleRequest(c.req.raw);
return response;
}
// New session: create transport and MCP server
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (newSessionId) => {
transports.set(newSessionId, transport);
},
});
// Clean up on close
transport.onclose = () => {
const sid = [...transports.entries()].find(
([_, t]) => t === transport,
)?.[0];
if (sid) transports.delete(sid);
};
const server = createMcpServer(db);
await server.connect(transport);
const response = await transport.handleRequest(c.req.raw);
return response;
});
mcpRoutes.get("/", async (c) => {
const sessionId = c.req.header("mcp-session-id");
if (!sessionId) {
return c.json({ error: "Session ID required" }, 400);
}
const transport = transports.get(sessionId);
if (!transport) {
return c.json({ error: "Session not found" }, 404);
}
const response = await transport.handleRequest(c.req.raw);
return response;
});
mcpRoutes.delete("/", async (c) => {
const sessionId = c.req.header("mcp-session-id");
if (!sessionId) {
return c.json({ error: "Session ID required" }, 400);
}
const transport = transports.get(sessionId);
if (!transport) {
return c.json({ error: "Session not found" }, 404);
}
await transport.close();
transports.delete(sessionId);
return c.text("", 200);
});

View File

@@ -0,0 +1,41 @@
import type { db as prodDb } from "@/db/index.ts";
import { getAllCategories } from "../../services/category.service.ts";
import { getAllItems } from "../../services/item.service.ts";
import { getAllSetups } from "../../services/setup.service.ts";
import { getAllThreads } from "../../services/thread.service.ts";
import { getGlobalTotals } from "../../services/totals.service.ts";
type Db = typeof prodDb;
export function getCollectionSummary(db: Db) {
const totals = getGlobalTotals(db);
const categories = getAllCategories(db);
const items = getAllItems(db);
const setups = getAllSetups(db);
const activeThreads = getAllThreads(db, false);
// Build items-by-category map
const itemsByCategory: Record<string, number> = {};
for (const item of items) {
const catName = item.categoryName;
itemsByCategory[catName] = (itemsByCategory[catName] ?? 0) + 1;
}
return {
overview: {
totalItems: totals?.itemCount ?? 0,
totalWeightGrams: totals?.totalWeight ?? 0,
totalCostCents: totals?.totalCost ?? 0,
categoryCount: categories.length,
setupCount: setups.length,
activeThreadCount: activeThreads.length,
},
itemsByCategory,
activeThreads: activeThreads.map((t) => ({
id: t.id,
name: t.name,
candidateCount: t.candidateCount,
category: t.categoryName,
})),
};
}

View File

@@ -0,0 +1,72 @@
import type { db as prodDb } from "@/db/index.ts";
import {
createCategory,
getAllCategories,
} from "../../services/category.service.ts";
type Db = typeof prodDb;
interface ToolResult {
content: Array<{ type: "text"; text: string }>;
}
function textResult(data: unknown): ToolResult {
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
}
function errorResult(message: string): ToolResult {
return {
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
};
}
export const categoryToolDefinitions = [
{
name: "list_categories",
description: "List all gear categories.",
inputSchema: {
type: "object" as const,
properties: {},
},
},
{
name: "create_category",
description: "Create a new gear category.",
inputSchema: {
type: "object" as const,
properties: {
name: { type: "string", description: "Category name" },
icon: {
type: "string",
description: "Icon name (defaults to 'package')",
},
},
required: ["name"],
},
},
];
export function registerCategoryTools(db: Db) {
return {
list_categories: async (): Promise<ToolResult> => {
try {
const cats = getAllCategories(db);
return textResult(cats);
} catch (err) {
return errorResult((err as Error).message);
}
},
create_category: async (args: {
name: string;
icon?: string;
}): Promise<ToolResult> => {
try {
const cat = createCategory(db, args);
return textResult(cat);
} catch (err) {
return errorResult((err as Error).message);
}
},
};
}

View File

@@ -0,0 +1,48 @@
import { fetchImageFromUrl } from "../../services/image.service.ts";
interface ToolResult {
content: Array<{ type: "text"; text: string }>;
}
function textResult(data: unknown): ToolResult {
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
}
function errorResult(message: string): ToolResult {
return {
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
};
}
export const imageToolDefinitions = [
{
name: "upload_image_from_url",
description:
"Fetch an image from a URL and save it locally. Returns the filename to use with create_item or add_candidate.",
inputSchema: {
type: "object" as const,
properties: {
url: {
type: "string",
description: "URL of the image to fetch (jpeg, png, or webp)",
},
},
required: ["url"],
},
},
];
export function registerImageTools() {
return {
upload_image_from_url: async (args: {
url: string;
}): Promise<ToolResult> => {
try {
const result = await fetchImageFromUrl(args.url);
return textResult(result);
} catch (err) {
return errorResult((err as Error).message);
}
},
};
}

View File

@@ -0,0 +1,190 @@
import type { db as prodDb } from "@/db/index.ts";
import {
createItem,
deleteItem,
getAllItems,
getItemById,
updateItem,
} from "../../services/item.service.ts";
type Db = typeof prodDb;
interface ToolResult {
content: Array<{ type: "text"; text: string }>;
}
function textResult(data: unknown): ToolResult {
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
}
function errorResult(message: string): ToolResult {
return {
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
};
}
export const itemToolDefinitions = [
{
name: "list_items",
description:
"List all items in the gear collection, optionally filtered by category.",
inputSchema: {
type: "object" as const,
properties: {
categoryId: {
type: "number",
description: "Filter items by category ID",
},
},
},
},
{
name: "get_item",
description: "Get a single item by its ID, including all details.",
inputSchema: {
type: "object" as const,
properties: {
id: { type: "number", description: "The item ID" },
},
required: ["id"],
},
},
{
name: "create_item",
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.",
inputSchema: {
type: "object" as const,
properties: {
name: { type: "string", description: "Item name" },
categoryId: { type: "number", description: "Category ID" },
weightGrams: { type: "number", description: "Weight in grams" },
priceCents: { type: "number", description: "Price in cents" },
notes: { type: "string", description: "Notes about the item" },
productUrl: { type: "string", description: "URL to the product page" },
imageFilename: {
type: "string",
description: "Filename of an uploaded image",
},
imageSourceUrl: {
type: "string",
description: "Original URL the image was fetched from",
},
},
required: ["name", "categoryId"],
},
},
{
name: "update_item",
description: "Update an existing item's fields.",
inputSchema: {
type: "object" as const,
properties: {
id: { type: "number", description: "The item ID to update" },
name: { type: "string", description: "Item name" },
categoryId: { type: "number", description: "Category ID" },
weightGrams: { type: "number", description: "Weight in grams" },
priceCents: { type: "number", description: "Price in cents" },
notes: { type: "string", description: "Notes about the item" },
productUrl: { type: "string", description: "URL to the product page" },
imageFilename: {
type: "string",
description: "Filename of an uploaded image",
},
imageSourceUrl: {
type: "string",
description: "Original URL the image was fetched from",
},
},
required: ["id"],
},
},
{
name: "delete_item",
description: "Delete an item from the gear collection by ID.",
inputSchema: {
type: "object" as const,
properties: {
id: { type: "number", description: "The item ID to delete" },
},
required: ["id"],
},
},
];
export function registerItemTools(db: Db) {
return {
list_items: async (args: { categoryId?: number }): Promise<ToolResult> => {
try {
const items = getAllItems(db);
if (args.categoryId) {
return textResult(
items.filter((i) => i.categoryId === args.categoryId),
);
}
return textResult(items);
} catch (err) {
return errorResult((err as Error).message);
}
},
get_item: async (args: { id: number }): Promise<ToolResult> => {
try {
const item = getItemById(db, args.id);
if (!item) return errorResult(`Item ${args.id} not found`);
return textResult(item);
} catch (err) {
return errorResult((err as Error).message);
}
},
create_item: async (args: {
name: string;
categoryId: number;
weightGrams?: number;
priceCents?: number;
notes?: string;
productUrl?: string;
imageFilename?: string;
imageSourceUrl?: string;
}): Promise<ToolResult> => {
try {
const item = createItem(db, args);
return textResult(item);
} catch (err) {
return errorResult((err as Error).message);
}
},
update_item: async (args: {
id: number;
name?: string;
categoryId?: number;
weightGrams?: number;
priceCents?: number;
notes?: string;
productUrl?: string;
imageFilename?: string;
imageSourceUrl?: string;
}): Promise<ToolResult> => {
try {
const { id, ...data } = args;
const item = updateItem(db, id, data);
if (!item) return errorResult(`Item ${id} not found`);
return textResult(item);
} catch (err) {
return errorResult((err as Error).message);
}
},
delete_item: async (args: { id: number }): Promise<ToolResult> => {
try {
const item = deleteItem(db, args.id);
if (!item) return errorResult(`Item ${args.id} not found`);
return textResult({ deleted: true, item });
} catch (err) {
return errorResult((err as Error).message);
}
},
};
}

View File

@@ -0,0 +1,131 @@
import type { db as prodDb } from "@/db/index.ts";
import {
createSetup,
getAllSetups,
getSetupWithItems,
syncSetupItems,
updateSetup,
} from "../../services/setup.service.ts";
type Db = typeof prodDb;
interface ToolResult {
content: Array<{ type: "text"; text: string }>;
}
function textResult(data: unknown): ToolResult {
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
}
function errorResult(message: string): ToolResult {
return {
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
};
}
export const setupToolDefinitions = [
{
name: "list_setups",
description:
"List all gear setups with item counts and weight/cost totals.",
inputSchema: {
type: "object" as const,
properties: {},
},
},
{
name: "get_setup",
description: "Get a setup with all its items and details.",
inputSchema: {
type: "object" as const,
properties: {
id: { type: "number", description: "Setup ID" },
},
required: ["id"],
},
},
{
name: "create_setup",
description: "Create a new gear setup (e.g. 'Bikepacking weekend').",
inputSchema: {
type: "object" as const,
properties: {
name: { type: "string", description: "Setup name" },
},
required: ["name"],
},
},
{
name: "update_setup",
description:
"Update a setup's name and/or replace its item list. Pass itemIds to set exactly which items belong to this setup.",
inputSchema: {
type: "object" as const,
properties: {
id: { type: "number", description: "Setup ID" },
name: { type: "string", description: "New setup name" },
itemIds: {
type: "array",
items: { type: "number" },
description: "Array of item IDs to include in the setup",
},
},
required: ["id"],
},
},
];
export function registerSetupTools(db: Db) {
return {
list_setups: async (): Promise<ToolResult> => {
try {
const setupList = getAllSetups(db);
return textResult(setupList);
} catch (err) {
return errorResult((err as Error).message);
}
},
get_setup: async (args: { id: number }): Promise<ToolResult> => {
try {
const setup = getSetupWithItems(db, args.id);
if (!setup) return errorResult(`Setup ${args.id} not found`);
return textResult(setup);
} catch (err) {
return errorResult((err as Error).message);
}
},
create_setup: async (args: { name: string }): Promise<ToolResult> => {
try {
const setup = createSetup(db, args);
return textResult(setup);
} catch (err) {
return errorResult((err as Error).message);
}
},
update_setup: async (args: {
id: number;
name?: string;
itemIds?: number[];
}): Promise<ToolResult> => {
try {
let setup = null;
if (args.name) {
setup = updateSetup(db, args.id, { name: args.name });
if (!setup) return errorResult(`Setup ${args.id} not found`);
}
if (args.itemIds) {
syncSetupItems(db, args.id, args.itemIds);
}
// Return updated setup with items
const result = getSetupWithItems(db, args.id);
if (!result) return errorResult(`Setup ${args.id} not found`);
return textResult(result);
} catch (err) {
return errorResult((err as Error).message);
}
},
};
}

View File

@@ -0,0 +1,252 @@
import type { db as prodDb } from "@/db/index.ts";
import {
createCandidate,
createThread,
deleteCandidate,
getAllThreads,
getThreadWithCandidates,
resolveThread,
updateCandidate,
} from "../../services/thread.service.ts";
type Db = typeof prodDb;
interface ToolResult {
content: Array<{ type: "text"; text: string }>;
}
function textResult(data: unknown): ToolResult {
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
}
function errorResult(message: string): ToolResult {
return {
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
};
}
export const threadToolDefinitions = [
{
name: "list_threads",
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.",
inputSchema: {
type: "object" as const,
properties: {
includeResolved: {
type: "boolean",
description:
"Include resolved threads (default: false, only active threads)",
},
},
},
},
{
name: "get_thread",
description:
"Get a thread with all its candidates for detailed comparison.",
inputSchema: {
type: "object" as const,
properties: {
id: { type: "number", description: "Thread ID" },
},
required: ["id"],
},
},
{
name: "create_thread",
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.",
inputSchema: {
type: "object" as const,
properties: {
name: {
type: "string",
description: "Thread name (e.g. 'Handlebar bag')",
},
categoryId: { type: "number", description: "Category ID" },
},
required: ["name", "categoryId"],
},
},
{
name: "resolve_thread",
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.",
inputSchema: {
type: "object" as const,
properties: {
threadId: { type: "number", description: "Thread ID" },
candidateId: {
type: "number",
description: "ID of the winning candidate",
},
},
required: ["threadId", "candidateId"],
},
},
{
name: "add_candidate",
description: "Add a candidate option to a research thread for comparison.",
inputSchema: {
type: "object" as const,
properties: {
threadId: { type: "number", description: "Thread ID" },
name: { type: "string", description: "Candidate name" },
categoryId: { type: "number", description: "Category ID" },
weightGrams: { type: "number", description: "Weight in grams" },
priceCents: { type: "number", description: "Price in cents" },
notes: { type: "string", description: "Notes" },
productUrl: { type: "string", description: "Product URL" },
imageFilename: { type: "string", description: "Image filename" },
pros: { type: "string", description: "Pros of this candidate" },
cons: { type: "string", description: "Cons of this candidate" },
},
required: ["threadId", "name", "categoryId"],
},
},
{
name: "update_candidate",
description:
"Update a candidate's details (name, price, pros, cons, etc.).",
inputSchema: {
type: "object" as const,
properties: {
id: { type: "number", description: "Candidate ID" },
name: { type: "string", description: "Candidate name" },
weightGrams: { type: "number", description: "Weight in grams" },
priceCents: { type: "number", description: "Price in cents" },
categoryId: { type: "number", description: "Category ID" },
notes: { type: "string", description: "Notes" },
productUrl: { type: "string", description: "Product URL" },
imageFilename: { type: "string", description: "Image filename" },
imageSourceUrl: { type: "string", description: "Image source URL" },
status: {
type: "string",
description: "Status: researching, ordered, or arrived",
},
pros: { type: "string", description: "Pros" },
cons: { type: "string", description: "Cons" },
},
required: ["id"],
},
},
{
name: "remove_candidate",
description: "Remove a candidate from a research thread.",
inputSchema: {
type: "object" as const,
properties: {
id: { type: "number", description: "Candidate ID to remove" },
},
required: ["id"],
},
},
];
export function registerThreadTools(db: Db) {
return {
list_threads: async (args: {
includeResolved?: boolean;
}): Promise<ToolResult> => {
try {
const threadList = getAllThreads(db, args.includeResolved ?? false);
return textResult(threadList);
} catch (err) {
return errorResult((err as Error).message);
}
},
get_thread: async (args: { id: number }): Promise<ToolResult> => {
try {
const thread = getThreadWithCandidates(db, args.id);
if (!thread) return errorResult(`Thread ${args.id} not found`);
return textResult(thread);
} catch (err) {
return errorResult((err as Error).message);
}
},
create_thread: async (args: {
name: string;
categoryId: number;
}): Promise<ToolResult> => {
try {
const thread = createThread(db, args);
return textResult(thread);
} catch (err) {
return errorResult((err as Error).message);
}
},
resolve_thread: async (args: {
threadId: number;
candidateId: number;
}): Promise<ToolResult> => {
try {
const result = resolveThread(db, args.threadId, args.candidateId);
if (!result.success) {
return errorResult(result.error ?? "Failed to resolve thread");
}
return textResult(result);
} catch (err) {
return errorResult((err as Error).message);
}
},
add_candidate: async (args: {
threadId: number;
name: string;
categoryId: number;
weightGrams?: number;
priceCents?: number;
notes?: string;
productUrl?: string;
imageFilename?: string;
pros?: string;
cons?: string;
}): Promise<ToolResult> => {
try {
const { threadId, ...data } = args;
const candidate = createCandidate(db, threadId, data);
return textResult(candidate);
} catch (err) {
return errorResult((err as Error).message);
}
},
update_candidate: async (args: {
id: number;
name?: string;
weightGrams?: number;
priceCents?: number;
categoryId?: number;
notes?: string;
productUrl?: string;
imageFilename?: string;
imageSourceUrl?: string;
status?: "researching" | "ordered" | "arrived";
pros?: string;
cons?: string;
}): Promise<ToolResult> => {
try {
const { id, ...data } = args;
const candidate = updateCandidate(db, id, data);
if (!candidate) return errorResult(`Candidate ${id} not found`);
return textResult(candidate);
} catch (err) {
return errorResult((err as Error).message);
}
},
remove_candidate: async (args: { id: number }): Promise<ToolResult> => {
try {
const candidate = deleteCandidate(db, args.id);
if (!candidate) return errorResult(`Candidate ${args.id} not found`);
return textResult({ deleted: true, candidate });
} catch (err) {
return errorResult((err as Error).message);
}
},
};
}

View File

@@ -0,0 +1,38 @@
import type { Context, Next } from "hono";
import { getCookie } from "hono/cookie";
import {
getSession,
getUserCount,
refreshSession,
verifyApiKey,
} from "../services/auth.service";
export async function requireAuth(c: Context, next: Next) {
const db = c.get("db");
// Check if any users exist at all
if (getUserCount(db) === 0) {
return c.json({ error: "setup_required" }, 403);
}
// Check API key first
const apiKey = c.req.header("X-API-Key");
if (apiKey) {
const valid = await verifyApiKey(db, apiKey);
if (valid) return next();
return c.json({ error: "Invalid API key" }, 401);
}
// Check session cookie
const sessionId = getCookie(c, "gearbox_session");
if (sessionId) {
const session = getSession(db, sessionId);
if (session) {
// Refresh session expiry on use
refreshSession(db, sessionId);
return next();
}
}
return c.json({ error: "Authentication required" }, 401);
}

194
src/server/routes/auth.ts Normal file
View File

@@ -0,0 +1,194 @@
import { zValidator } from "@hono/zod-validator";
import { eq } from "drizzle-orm";
import { Hono } from "hono";
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
import { z } from "zod";
import { users } from "../../db/schema.ts";
import { requireAuth } from "../middleware/auth.ts";
import {
changePassword,
createApiKey,
createSession,
createUser,
deleteApiKey,
deleteSession,
getSession,
getUserCount,
listApiKeys,
verifyPassword,
} from "../services/auth.service.ts";
type Env = { Variables: { db?: any } };
const loginSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
const setupSchema = z.object({
username: z.string().min(1),
password: z.string().min(6),
});
const changePasswordSchema = z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(6),
});
const createKeySchema = z.object({ name: z.string().min(1) });
const COOKIE_NAME = "gearbox_session";
const COOKIE_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds
const app = new Hono<Env>();
// ── Public routes ───────────────────────────────────────────────────
app.get("/me", (c) => {
const db = c.get("db");
const sessionId = getCookie(c, COOKIE_NAME);
if (sessionId) {
const session = getSession(db, sessionId);
if (session) {
return c.json({
user: { id: session.userId },
setupRequired: false,
});
}
}
const setupRequired = getUserCount(db) === 0;
return c.json({ user: null, setupRequired });
});
app.post("/setup", zValidator("json", setupSchema), async (c) => {
const db = c.get("db");
if (getUserCount(db) > 0) {
return c.json({ error: "Setup already completed" }, 403);
}
const { username, password } = c.req.valid("json");
const user = await createUser(db, username, password);
const session = createSession(db, user.id);
setCookie(c, COOKIE_NAME, session.id, {
httpOnly: true,
sameSite: "Lax",
path: "/",
maxAge: COOKIE_MAX_AGE,
});
return c.json({ username: user.username }, 201);
});
app.post("/login", zValidator("json", loginSchema), async (c) => {
const db = c.get("db");
const { username, password } = c.req.valid("json");
const user = await verifyPassword(db, username, password);
if (!user) {
return c.json({ error: "Invalid credentials" }, 401);
}
const session = createSession(db, user.id);
setCookie(c, COOKIE_NAME, session.id, {
httpOnly: true,
sameSite: "Lax",
path: "/",
maxAge: COOKIE_MAX_AGE,
});
return c.json({ username: user.username });
});
app.post("/logout", (c) => {
const db = c.get("db");
const sessionId = getCookie(c, COOKIE_NAME);
if (sessionId) {
deleteSession(db, sessionId);
}
deleteCookie(c, COOKIE_NAME, { path: "/" });
return c.json({ ok: true });
});
// ── Protected routes ────────────────────────────────────────────────
app.put(
"/password",
requireAuth,
zValidator("json", changePasswordSchema),
async (c) => {
const db = c.get("db");
const sessionId = getCookie(c, COOKIE_NAME);
if (!sessionId) {
return c.json({ error: "Session required for password change" }, 401);
}
const session = getSession(db, sessionId);
if (!session) {
return c.json({ error: "Session required for password change" }, 401);
}
const userRecord = db
.select()
.from(users)
.where(eq(users.id, session.userId))
.get();
if (!userRecord) {
return c.json({ error: "User not found" }, 404);
}
const { currentPassword, newPassword } = c.req.valid("json");
const changed = await changePassword(
db,
userRecord.username,
currentPassword,
newPassword,
);
if (!changed) {
return c.json({ error: "Invalid current password" }, 401);
}
return c.json({ ok: true });
},
);
app.get("/keys", requireAuth, (c) => {
const db = c.get("db");
const keys = listApiKeys(db);
return c.json(keys);
});
app.post(
"/keys",
requireAuth,
zValidator("json", createKeySchema),
async (c) => {
const db = c.get("db");
const { name } = c.req.valid("json");
const result = await createApiKey(db, name);
return c.json(
{
id: result.id,
name: result.name,
key: result.rawKey,
prefix: result.keyPrefix,
},
201,
);
},
);
app.delete("/keys/:id", requireAuth, (c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
deleteApiKey(db, id);
return c.json({ ok: true });
});
export const authRoutes = app;

View File

@@ -1,13 +1,35 @@
import { randomUUID } from "node:crypto";
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { z } from "zod";
import { fetchImageFromUrl } from "../services/image.service";
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
const fromUrlSchema = z.object({ url: z.string().url("Invalid URL") });
const app = new Hono();
app.post("/from-url", zValidator("json", fromUrlSchema), async (c) => {
const { url } = c.req.valid("json");
try {
const result = await fetchImageFromUrl(url);
return c.json(result, 201);
} catch (err) {
const message = (err as Error).message;
// Known validation errors from the service
const isValidationError =
message.startsWith("Invalid") ||
message.startsWith("URL must") ||
message.startsWith("File too") ||
message.startsWith("HTTP ");
return c.json({ error: message }, isValidationError ? 400 : 500);
}
});
app.post("/", async (c) => {
const body = await c.req.parseBody();
const file = body.image;

View File

@@ -0,0 +1,158 @@
import { randomBytes } from "node:crypto";
import { count, eq } from "drizzle-orm";
import { db as prodDb } from "../../db/index.ts";
import { apiKeys, sessions, users } from "../../db/schema.ts";
type Db = typeof prodDb;
// ── User Management ──────────────────────────────────────────────────
export async function createUser(
db: Db = prodDb,
username: string,
password: string,
) {
const passwordHash = await Bun.password.hash(password);
return db.insert(users).values({ username, passwordHash }).returning().get();
}
export async function verifyPassword(
db: Db = prodDb,
username: string,
password: string,
) {
const user = db
.select()
.from(users)
.where(eq(users.username, username))
.get();
if (!user) return null;
const valid = await Bun.password.verify(password, user.passwordHash);
return valid ? user : null;
}
export function getUserCount(db: Db = prodDb): number {
const result = db.select({ value: count() }).from(users).get();
return result?.value ?? 0;
}
export async function changePassword(
db: Db = prodDb,
username: string,
currentPassword: string,
newPassword: string,
): Promise<boolean> {
const user = await verifyPassword(db, username, currentPassword);
if (!user) return false;
const newHash = await Bun.password.hash(newPassword);
db.update(users)
.set({ passwordHash: newHash })
.where(eq(users.id, user.id))
.run();
return true;
}
// ── Session Management ───────────────────────────────────────────────
export function createSession(
db: Db = prodDb,
userId: number,
expiryDays = 30,
) {
const id = randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000);
return db
.insert(sessions)
.values({ id, userId, expiresAt })
.returning()
.get();
}
export function getSession(db: Db = prodDb, sessionId: string) {
const session = db
.select()
.from(sessions)
.where(eq(sessions.id, sessionId))
.get();
if (!session) return null;
if (session.expiresAt < new Date()) {
db.delete(sessions).where(eq(sessions.id, sessionId)).run();
return null;
}
return session;
}
export function deleteSession(db: Db = prodDb, sessionId: string) {
db.delete(sessions).where(eq(sessions.id, sessionId)).run();
}
export function refreshSession(
db: Db = prodDb,
sessionId: string,
expiryDays = 30,
) {
const expiresAt = new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000);
db.update(sessions)
.set({ expiresAt })
.where(eq(sessions.id, sessionId))
.run();
}
// ── API Key Management ───────────────────────────────────────────────
export async function createApiKey(db: Db = prodDb, name: string) {
const rawKey = randomBytes(32).toString("hex");
const keyHash = await Bun.password.hash(rawKey);
const keyPrefix = rawKey.slice(0, 8);
const record = db
.insert(apiKeys)
.values({ name, keyHash, keyPrefix })
.returning()
.get();
return { ...record, rawKey };
}
export async function verifyApiKey(
db: Db = prodDb,
rawKey: string,
): Promise<boolean> {
const prefix = rawKey.slice(0, 8);
const candidates = db
.select()
.from(apiKeys)
.where(eq(apiKeys.keyPrefix, prefix))
.all();
for (const candidate of candidates) {
const valid = await Bun.password.verify(rawKey, candidate.keyHash);
if (valid) return true;
}
return false;
}
export function listApiKeys(db: Db = prodDb) {
return db
.select({
id: apiKeys.id,
name: apiKeys.name,
keyPrefix: apiKeys.keyPrefix,
createdAt: apiKeys.createdAt,
})
.from(apiKeys)
.all();
}
export function deleteApiKey(db: Db = prodDb, id: number) {
db.delete(apiKeys).where(eq(apiKeys.id, id)).run();
}

View File

@@ -0,0 +1,82 @@
import { randomUUID } from "node:crypto";
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
const FETCH_TIMEOUT = 10_000; // 10 seconds
interface FetchImageResult {
filename: string;
sourceUrl: string;
}
export async function fetchImageFromUrl(
url: string,
uploadsDir = "uploads",
): Promise<FetchImageResult> {
// Validate URL format
let parsedUrl: URL;
try {
parsedUrl = new URL(url);
} catch {
throw new Error("Invalid URL format");
}
if (!["http:", "https:"].includes(parsedUrl.protocol)) {
throw new Error("URL must use HTTP or HTTPS");
}
// Fetch with timeout
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
let response: Response;
try {
response = await fetch(url, { signal: controller.signal });
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
throw new Error("Request timed out");
}
throw new Error(`Failed to fetch image: ${(err as Error).message}`);
} finally {
clearTimeout(timeout);
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}: Failed to fetch image`);
}
// Validate content type
const contentType = response.headers
.get("content-type")
?.split(";")[0]
.trim();
if (!contentType || !ALLOWED_TYPES.includes(contentType)) {
throw new Error(
`Invalid content type: ${contentType ?? "unknown"}. Accepted: jpeg, png, webp`,
);
}
// Check content length if available
const contentLength = response.headers.get("content-length");
if (contentLength && Number.parseInt(contentLength, 10) > MAX_SIZE) {
throw new Error("File too large. Maximum size is 5MB");
}
// Read body and check actual size
const buffer = await response.arrayBuffer();
if (buffer.byteLength > MAX_SIZE) {
throw new Error("File too large. Maximum size is 5MB");
}
// Determine extension
const ext = contentType === "image/jpeg" ? "jpg" : contentType.split("/")[1];
const filename = `${Date.now()}-${randomUUID()}.${ext}`;
// Ensure directory exists and write
await mkdir(uploadsDir, { recursive: true });
await Bun.write(join(uploadsDir, filename), buffer);
return { filename, sourceUrl: url };
}

View File

@@ -16,6 +16,7 @@ export function getAllItems(db: Db = prodDb) {
notes: items.notes,
productUrl: items.productUrl,
imageFilename: items.imageFilename,
imageSourceUrl: items.imageSourceUrl,
createdAt: items.createdAt,
updatedAt: items.updatedAt,
categoryName: categories.name,
@@ -38,6 +39,7 @@ export function getItemById(db: Db = prodDb, id: number) {
notes: items.notes,
productUrl: items.productUrl,
imageFilename: items.imageFilename,
imageSourceUrl: items.imageSourceUrl,
createdAt: items.createdAt,
updatedAt: items.updatedAt,
})
@@ -65,6 +67,7 @@ export function createItem(
notes: data.notes ?? null,
productUrl: data.productUrl ?? null,
imageFilename: data.imageFilename ?? null,
imageSourceUrl: data.imageSourceUrl ?? null,
})
.returning()
.get();
@@ -81,6 +84,7 @@ export function updateItem(
notes: string;
productUrl: string;
imageFilename: string;
imageSourceUrl: string;
}>,
) {
// Check if item exists first

View File

@@ -76,6 +76,7 @@ export function getThreadWithCandidates(db: Db = prodDb, threadId: number) {
notes: threadCandidates.notes,
productUrl: threadCandidates.productUrl,
imageFilename: threadCandidates.imageFilename,
imageSourceUrl: threadCandidates.imageSourceUrl,
status: threadCandidates.status,
pros: threadCandidates.pros,
cons: threadCandidates.cons,
@@ -144,6 +145,7 @@ export function createCandidate(
name: string;
categoryId: number;
imageFilename?: string;
imageSourceUrl?: string;
},
) {
const maxRow = db
@@ -165,6 +167,7 @@ export function createCandidate(
notes: data.notes ?? null,
productUrl: data.productUrl ?? null,
imageFilename: data.imageFilename ?? null,
imageSourceUrl: data.imageSourceUrl ?? null,
status: data.status ?? "researching",
pros: data.pros ?? null,
cons: data.cons ?? null,
@@ -185,6 +188,7 @@ export function updateCandidate(
notes: string;
productUrl: string;
imageFilename: string;
imageSourceUrl: string;
status: "researching" | "ordered" | "arrived";
pros: string;
cons: string;
@@ -294,6 +298,7 @@ export function resolveThread(
notes: candidate.notes,
productUrl: candidate.productUrl,
imageFilename: candidate.imageFilename,
imageSourceUrl: candidate.imageSourceUrl,
})
.returning()
.get();

View File

@@ -8,6 +8,7 @@ export const createItemSchema = z.object({
notes: z.string().optional(),
productUrl: z.string().url().optional().or(z.literal("")),
imageFilename: z.string().optional(),
imageSourceUrl: z.string().url().optional().or(z.literal("")),
});
export const updateItemSchema = createItemSchema.partial().extend({
@@ -52,6 +53,7 @@ export const createCandidateSchema = z.object({
notes: z.string().optional(),
productUrl: z.string().url().optional().or(z.literal("")),
imageFilename: z.string().optional(),
imageSourceUrl: z.string().url().optional().or(z.literal("")),
status: candidateStatusSchema.optional(),
pros: z.string().optional(),
cons: z.string().optional(),

View File

@@ -26,6 +26,7 @@ export function createTestDb() {
notes TEXT,
product_url TEXT,
image_filename TEXT,
image_source_url TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
)
@@ -54,6 +55,7 @@ export function createTestDb() {
notes TEXT,
product_url TEXT,
image_filename TEXT,
image_source_url TEXT,
status TEXT NOT NULL DEFAULT 'researching',
pros TEXT,
cons TEXT,
@@ -88,6 +90,33 @@ export function createTestDb() {
)
`);
sqlite.run(`
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
)
`);
sqlite.run(`
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL
)
`);
sqlite.run(`
CREATE TABLE api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
key_hash TEXT NOT NULL,
key_prefix TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
)
`);
const db = drizzle(sqlite, { schema });
// Seed default Uncategorized category

253
tests/mcp/tools.test.ts Normal file
View File

@@ -0,0 +1,253 @@
import { describe, expect, test } from "bun:test";
import { getCollectionSummary } from "../../src/server/mcp/resources/collection.ts";
import { registerCategoryTools } from "../../src/server/mcp/tools/categories.ts";
import { registerItemTools } from "../../src/server/mcp/tools/items.ts";
import { registerSetupTools } from "../../src/server/mcp/tools/setups.ts";
import { registerThreadTools } from "../../src/server/mcp/tools/threads.ts";
import { createTestDb } from "../helpers/db.ts";
function parseResult(result: {
content: Array<{ type: string; text: string }>;
}) {
return JSON.parse(result.content[0].text);
}
describe("MCP Item Tools", () => {
test("list_items returns array", async () => {
const db = createTestDb();
const tools = registerItemTools(db);
const result = await tools.list_items({});
const data = parseResult(result);
expect(Array.isArray(data)).toBe(true);
});
test("create_item creates and returns item", async () => {
const db = createTestDb();
const tools = registerItemTools(db);
const result = await tools.create_item({
name: "Test Tent",
categoryId: 1,
weightGrams: 1200,
priceCents: 35000,
});
const data = parseResult(result);
expect(data.name).toBe("Test Tent");
expect(data.weightGrams).toBe(1200);
expect(data.priceCents).toBe(35000);
expect(data.id).toBeDefined();
});
test("get_item retrieves by ID", async () => {
const db = createTestDb();
const tools = registerItemTools(db);
const created = parseResult(
await tools.create_item({ name: "Sleeping Bag", categoryId: 1 }),
);
const result = await tools.get_item({ id: created.id });
const data = parseResult(result);
expect(data.name).toBe("Sleeping Bag");
expect(data.id).toBe(created.id);
});
test("get_item returns error for missing item", async () => {
const db = createTestDb();
const tools = registerItemTools(db);
const result = await tools.get_item({ id: 999 });
const data = parseResult(result);
expect(data.error).toContain("not found");
});
test("delete_item removes item", async () => {
const db = createTestDb();
const tools = registerItemTools(db);
const created = parseResult(
await tools.create_item({ name: "To Delete", categoryId: 1 }),
);
const deleteResult = await tools.delete_item({ id: created.id });
const data = parseResult(deleteResult);
expect(data.deleted).toBe(true);
// Verify it's gone
const getResult = await tools.get_item({ id: created.id });
const getData = parseResult(getResult);
expect(getData.error).toContain("not found");
});
});
describe("MCP Category Tools", () => {
test("list_categories returns array with Uncategorized", async () => {
const db = createTestDb();
const tools = registerCategoryTools(db);
const result = await tools.list_categories();
const data = parseResult(result);
expect(Array.isArray(data)).toBe(true);
expect(data.length).toBeGreaterThanOrEqual(1);
expect(data.some((c: any) => c.name === "Uncategorized")).toBe(true);
});
test("create_category creates a new category", async () => {
const db = createTestDb();
const tools = registerCategoryTools(db);
const result = await tools.create_category({
name: "Shelter",
icon: "tent",
});
const data = parseResult(result);
expect(data.name).toBe("Shelter");
expect(data.icon).toBe("tent");
});
});
describe("MCP Thread Tools", () => {
test("create_thread starts a thread with status active", async () => {
const db = createTestDb();
const tools = registerThreadTools(db);
const result = await tools.create_thread({
name: "Handlebar Bag",
categoryId: 1,
});
const data = parseResult(result);
expect(data.name).toBe("Handlebar Bag");
expect(data.status).toBe("active");
});
test("add_candidate adds to thread", async () => {
const db = createTestDb();
const tools = registerThreadTools(db);
const thread = parseResult(
await tools.create_thread({ name: "Saddle Bag", categoryId: 1 }),
);
const result = await tools.add_candidate({
threadId: thread.id,
name: "Apidura Racing",
categoryId: 1,
priceCents: 8500,
pros: "Lightweight",
cons: "Expensive",
});
const data = parseResult(result);
expect(data.name).toBe("Apidura Racing");
expect(data.threadId).toBe(thread.id);
expect(data.pros).toBe("Lightweight");
});
test("resolve_thread picks winner and creates item", async () => {
const db = createTestDb();
const threadTools = registerThreadTools(db);
const itemTools = registerItemTools(db);
// Create thread with two candidates
const thread = parseResult(
await threadTools.create_thread({ name: "Frame Bag", categoryId: 1 }),
);
const candidate1 = parseResult(
await threadTools.add_candidate({
threadId: thread.id,
name: "Revelate Tangle",
categoryId: 1,
priceCents: 12000,
}),
);
await threadTools.add_candidate({
threadId: thread.id,
name: "Ortlieb Frame Pack",
categoryId: 1,
priceCents: 9000,
});
// Resolve with first candidate
const resolveResult = await threadTools.resolve_thread({
threadId: thread.id,
candidateId: candidate1.id,
});
const resolveData = parseResult(resolveResult);
expect(resolveData.success).toBe(true);
expect(resolveData.item.name).toBe("Revelate Tangle");
// Check item was added to collection
const items = parseResult(await itemTools.list_items({}));
expect(items.some((i: any) => i.name === "Revelate Tangle")).toBe(true);
// Check thread is now resolved
const threadList = parseResult(
await threadTools.list_threads({ includeResolved: true }),
);
const resolved = threadList.find((t: any) => t.id === thread.id);
expect(resolved.status).toBe("resolved");
});
});
describe("MCP Setup Tools", () => {
test("create_setup and list_setups", async () => {
const db = createTestDb();
const tools = registerSetupTools(db);
await tools.create_setup({ name: "Weekend Trip" });
const result = await tools.list_setups();
const data = parseResult(result);
expect(data.length).toBe(1);
expect(data[0].name).toBe("Weekend Trip");
});
test("get_setup returns setup with items", async () => {
const db = createTestDb();
const setupTools = registerSetupTools(db);
const itemTools = registerItemTools(db);
const setup = parseResult(
await setupTools.create_setup({ name: "Overnighter" }),
);
const item = parseResult(
await itemTools.create_item({ name: "Bivvy", categoryId: 1 }),
);
await setupTools.update_setup({ id: setup.id, itemIds: [item.id] });
const result = await setupTools.get_setup({ id: setup.id });
const data = parseResult(result);
expect(data.name).toBe("Overnighter");
expect(data.items.length).toBe(1);
expect(data.items[0].name).toBe("Bivvy");
});
});
describe("MCP Collection Summary Resource", () => {
test("returns overview with correct counts", () => {
const db = createTestDb();
const summary = getCollectionSummary(db);
expect(summary.overview).toBeDefined();
expect(summary.overview.totalItems).toBe(0);
expect(summary.overview.categoryCount).toBe(1); // Uncategorized
expect(summary.itemsByCategory).toBeDefined();
expect(summary.activeThreads).toBeDefined();
expect(Array.isArray(summary.activeThreads)).toBe(true);
});
test("reflects items and threads after creation", async () => {
const db = createTestDb();
const itemTools = registerItemTools(db);
const threadTools = registerThreadTools(db);
await itemTools.create_item({
name: "Tent",
categoryId: 1,
weightGrams: 1500,
});
await itemTools.create_item({
name: "Sleeping Pad",
categoryId: 1,
weightGrams: 500,
});
await threadTools.create_thread({
name: "Cook System",
categoryId: 1,
});
const summary = getCollectionSummary(db);
expect(summary.overview.totalItems).toBe(2);
expect(summary.overview.totalWeightGrams).toBe(2000);
expect(summary.overview.activeThreadCount).toBe(1);
expect(summary.itemsByCategory.Uncategorized).toBe(2);
expect(summary.activeThreads.length).toBe(1);
expect(summary.activeThreads[0].name).toBe("Cook System");
});
});

View File

@@ -0,0 +1,86 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { Hono } from "hono";
import { requireAuth } from "../../src/server/middleware/auth";
import {
createApiKey,
createSession,
createUser,
} from "../../src/server/services/auth.service";
import { createTestDb } from "../helpers/db";
let db: ReturnType<typeof createTestDb>;
beforeEach(() => {
db = createTestDb();
});
function createApp() {
const app = new Hono<{ Variables: { db?: any } }>();
app.use("*", async (c, next) => {
c.set("db", db);
await next();
});
// Public GET
app.get("/items", (c) => c.json({ ok: true }));
// Protected POST
app.post("/items", requireAuth, (c) => c.json({ ok: true }));
return app;
}
describe("auth middleware", () => {
test("allows GET requests without auth (middleware not applied to GET)", async () => {
const app = createApp();
const res = await app.request("/items");
expect(res.status).toBe(200);
});
test("returns 403 setup_required when no users exist", async () => {
const app = createApp();
const res = await app.request("/items", { method: "POST" });
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toBe("setup_required");
});
test("rejects POST without auth when users exist", async () => {
const app = createApp();
await createUser(db, "admin", "pass");
const res = await app.request("/items", { method: "POST" });
expect(res.status).toBe(401);
});
test("allows POST with valid session cookie", async () => {
const app = createApp();
const user = await createUser(db, "admin", "pass");
const session = createSession(db, user.id);
const res = await app.request("/items", {
method: "POST",
headers: { Cookie: `gearbox_session=${session.id}` },
});
expect(res.status).toBe(200);
});
test("allows POST with valid API key", async () => {
const app = createApp();
await createUser(db, "admin", "pass");
const key = await createApiKey(db, "test");
const res = await app.request("/items", {
method: "POST",
headers: { "X-API-Key": key.rawKey },
});
expect(res.status).toBe(200);
});
test("rejects POST with invalid API key", async () => {
const app = createApp();
await createUser(db, "admin", "pass");
const res = await app.request("/items", {
method: "POST",
headers: { "X-API-Key": "invalid" },
});
expect(res.status).toBe(401);
});
});

128
tests/routes/auth.test.ts Normal file
View File

@@ -0,0 +1,128 @@
import { beforeEach, describe, expect, it } from "bun:test";
import { Hono } from "hono";
import { authRoutes } from "../../src/server/routes/auth.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("/api/auth", authRoutes);
return { app, db };
}
describe("Auth Routes", () => {
let app: Hono;
beforeEach(() => {
const testApp = createTestApp();
app = testApp.app;
});
describe("GET /api/auth/me", () => {
it("returns null user and setupRequired true when no users exist", async () => {
const res = await app.request("/api/auth/me");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.user).toBeNull();
expect(body.setupRequired).toBe(true);
});
});
describe("POST /api/auth/setup", () => {
it("creates first user and returns 201", async () => {
const res = await app.request("/api/auth/setup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "admin", password: "secret123" }),
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.username).toBe("admin");
// Should set a session cookie
const setCookie = res.headers.get("set-cookie");
expect(setCookie).toContain("gearbox_session");
});
it("rejects second setup attempt with 403", async () => {
// First setup
await app.request("/api/auth/setup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "admin", password: "secret123" }),
});
// Second attempt
const res = await app.request("/api/auth/setup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "other", password: "secret456" }),
});
expect(res.status).toBe(403);
});
it("rejects password shorter than 6 characters", async () => {
const res = await app.request("/api/auth/setup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "admin", password: "short" }),
});
expect(res.status).toBe(400);
});
});
describe("POST /api/auth/login", () => {
beforeEach(async () => {
// Create a user first
await app.request("/api/auth/setup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "admin", password: "secret123" }),
});
});
it("returns session cookie on valid login", async () => {
const res = await app.request("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "admin", password: "secret123" }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.username).toBe("admin");
const setCookie = res.headers.get("set-cookie");
expect(setCookie).toContain("gearbox_session");
});
it("rejects invalid credentials with 401", async () => {
const res = await app.request("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "admin", password: "wrongpassword" }),
});
expect(res.status).toBe(401);
});
});
describe("POST /api/auth/logout", () => {
it("clears session cookie", async () => {
const res = await app.request("/api/auth/logout", {
method: "POST",
});
expect(res.status).toBe(200);
});
});
});

View File

@@ -0,0 +1,26 @@
import { describe, expect, test } from "bun:test";
import { Hono } from "hono";
import { imageRoutes } from "../../src/server/routes/images";
const app = new Hono();
app.route("/api/images", imageRoutes);
describe("POST /api/images/from-url", () => {
test("returns 400 for missing URL", async () => {
const res = await app.request("/api/images/from-url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(400);
});
test("returns 400 for invalid URL", async () => {
const res = await app.request("/api/images/from-url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: "not-a-url" }),
});
expect(res.status).toBe(400);
});
});

View File

@@ -0,0 +1,171 @@
import { beforeEach, describe, expect, it } from "bun:test";
import {
changePassword,
createApiKey,
createSession,
createUser,
deleteApiKey,
deleteSession,
getSession,
getUserCount,
listApiKeys,
verifyApiKey,
verifyPassword,
} from "../../src/server/services/auth.service.ts";
import { createTestDb } from "../helpers/db.ts";
describe("Auth Service", () => {
let db: ReturnType<typeof createTestDb>;
beforeEach(() => {
db = createTestDb();
});
describe("User Management", () => {
it("creates a user with hashed password (hash !== plaintext)", async () => {
const user = await createUser(db, "admin", "secret123");
expect(user).toBeDefined();
expect(user.id).toBeGreaterThan(0);
expect(user.username).toBe("admin");
expect(user.passwordHash).not.toBe("secret123");
expect(user.passwordHash.length).toBeGreaterThan(0);
});
it("verifies correct password returns user", async () => {
await createUser(db, "admin", "secret123");
const user = await verifyPassword(db, "admin", "secret123");
expect(user).not.toBeNull();
expect(user!.username).toBe("admin");
});
it("rejects incorrect password returns null", async () => {
await createUser(db, "admin", "secret123");
const user = await verifyPassword(db, "admin", "wrongpassword");
expect(user).toBeNull();
});
it("getUserCount returns 0 then 1", async () => {
const countBefore = getUserCount(db);
expect(countBefore).toBe(0);
await createUser(db, "admin", "secret123");
const countAfter = getUserCount(db);
expect(countAfter).toBe(1);
});
it("changes password successfully", async () => {
await createUser(db, "admin", "oldpass");
const changed = await changePassword(db, "admin", "oldpass", "newpass");
expect(changed).toBe(true);
// Verify new password works
const user = await verifyPassword(db, "admin", "newpass");
expect(user).not.toBeNull();
// Verify old password no longer works
const oldAttempt = await verifyPassword(db, "admin", "oldpass");
expect(oldAttempt).toBeNull();
});
it("rejects password change with wrong current password", async () => {
await createUser(db, "admin", "secret123");
const changed = await changePassword(
db,
"admin",
"wrongcurrent",
"newpass",
);
expect(changed).toBe(false);
});
});
describe("Session Management", () => {
it("creates and retrieves a session (id length is 64 hex chars)", async () => {
const user = await createUser(db, "admin", "secret123");
const session = createSession(db, user.id);
expect(session).toBeDefined();
expect(session.id).toHaveLength(64);
expect(session.userId).toBe(user.id);
expect(session.expiresAt).toBeInstanceOf(Date);
const retrieved = getSession(db, session.id);
expect(retrieved).not.toBeNull();
expect(retrieved!.id).toBe(session.id);
});
it("returns null for expired session (expiryDays = -1)", async () => {
const user = await createUser(db, "admin", "secret123");
const session = createSession(db, user.id, -1);
const retrieved = getSession(db, session.id);
expect(retrieved).toBeNull();
});
it("deletes a session", async () => {
const user = await createUser(db, "admin", "secret123");
const session = createSession(db, user.id);
deleteSession(db, session.id);
const retrieved = getSession(db, session.id);
expect(retrieved).toBeNull();
});
});
describe("API Key Management", () => {
it("creates key and returns raw key once (length > 16, prefix matches first 8 chars)", async () => {
const result = await createApiKey(db, "test-key");
expect(result).toBeDefined();
expect(result.rawKey).toBeDefined();
expect(result.rawKey.length).toBeGreaterThan(16);
expect(result.keyPrefix).toBe(result.rawKey.slice(0, 8));
expect(result.name).toBe("test-key");
});
it("verifies valid key returns true", async () => {
const result = await createApiKey(db, "test-key");
const isValid = await verifyApiKey(db, result.rawKey);
expect(isValid).toBe(true);
});
it("rejects invalid key returns false", async () => {
await createApiKey(db, "test-key");
const isValid = await verifyApiKey(db, "invalidkey12345678");
expect(isValid).toBe(false);
});
it("deletes key so it is no longer valid", async () => {
const result = await createApiKey(db, "test-key");
deleteApiKey(db, result.id);
const isValid = await verifyApiKey(db, result.rawKey);
expect(isValid).toBe(false);
});
it("listApiKeys returns keys without hashes", async () => {
await createApiKey(db, "key-one");
await createApiKey(db, "key-two");
const keys = listApiKeys(db);
expect(keys).toHaveLength(2);
expect(keys[0].name).toBe("key-one");
expect(keys[1].name).toBe("key-two");
// Ensure no hash is exposed
for (const key of keys) {
expect(key).toHaveProperty("id");
expect(key).toHaveProperty("name");
expect(key).toHaveProperty("keyPrefix");
expect(key).toHaveProperty("createdAt");
expect(key).not.toHaveProperty("keyHash");
}
});
});
});

View File

@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it } from "bun:test";
import { existsSync, rmSync } from "node:fs";
import { fetchImageFromUrl } from "../../src/server/services/image.service.ts";
const TEST_UPLOADS_DIR = "test-uploads";
describe("Image Service", () => {
afterEach(() => {
if (existsSync(TEST_UPLOADS_DIR)) {
rmSync(TEST_UPLOADS_DIR, { recursive: true });
}
});
describe("fetchImageFromUrl", () => {
it("fetches a valid image URL and saves to disk", async () => {
const imageUrl =
"https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png";
const result = await fetchImageFromUrl(imageUrl, TEST_UPLOADS_DIR);
expect(result.filename).toMatch(/^\d+-[\w-]+\.png$/);
expect(result.sourceUrl).toBe(imageUrl);
const filePath = `${TEST_UPLOADS_DIR}/${result.filename}`;
expect(existsSync(filePath)).toBe(true);
});
it("rejects non-image content type", async () => {
await expect(
fetchImageFromUrl("https://example.com/", TEST_UPLOADS_DIR),
).rejects.toThrow("Invalid content type");
});
it("rejects invalid URL format", async () => {
await expect(
fetchImageFromUrl("not-a-url", TEST_UPLOADS_DIR),
).rejects.toThrow("Invalid URL format");
});
it("rejects non-HTTP protocols", async () => {
await expect(
fetchImageFromUrl("ftp://example.com/image.png", TEST_UPLOADS_DIR),
).rejects.toThrow("URL must use HTTP or HTTPS");
});
});
});