29 Commits

Author SHA1 Message Date
6ae41c4ffa fix: add missing framer-motion dependency
All checks were successful
CI / ci (push) Successful in 24s
Was imported in collection/index.tsx but not in package.json,
causing build failures in CI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:08:37 +02:00
be168c8329 fix: replace network-dependent image tests with local HTTP server
Some checks failed
CI / ci (push) Failing after 25s
Use Bun.serve to create a local test server instead of hitting
external URLs. Fixes flaky test in CI environments without
network access.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:07:16 +02:00
9191f0fe24 fix: resolve all lint errors — exclude generated dirs, auto-fix source
Some checks failed
CI / ci (push) Failing after 20s
Exclude drizzle/ and .planning/ from Biome (generated files with
incompatible formatting). Auto-fix import ordering and formatting
in existing source files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:05:10 +02:00
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
0998f65c6f chore: add .worktrees/ to gitignore
Some checks failed
CI / ci (push) Failing after 19s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:09:50 +02:00
a6a4ffda2e docs: add implementation plans for image URL fetching, auth, and MCP server
Three detailed implementation plans with TDD, exact code, and step-by-step tasks:
- Image URL fetching: 4 tasks (schema, Zod, service, route)
- Authentication: 9 tasks (tables, service, middleware, routes, frontend)
- MCP server: 9 tasks (SDK, tools, resources, Hono integration)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:06:46 +02:00
dde2fc241d docs: add design specs for image URL fetching, auth, and MCP server
Three independent feature specs covering:
- API endpoint for fetching images from URLs with local storage
- Public-read/authenticated-write auth with sessions and API keys
- Built-in MCP server for Claude Code/Desktop integration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:53:51 +02:00
32d6babf24 update lock file
Some checks failed
CI / ci (push) Has been cancelled
2026-03-26 08:41:21 +01:00
6fe029f531 Use prebuilt image and rename compose file
Some checks failed
CI / ci (push) Failing after 14s
Replace local build with prebuilt image
Image: gitea.jeanlucmakiola.de/makiolaj/gearbox:latest
2026-03-24 09:28:31 +01:00
725901623b chore: unify dev setup with concurrently
Adds concurrently to start both the Vite frontend and Hono backend simultaneously in one terminal via the `bun run dev` command. Also updates documentation in README.md and CLAUDE.md to reflect the new development workflow.
2026-03-24 09:28:31 +01:00
57 changed files with 8932 additions and 15 deletions

3
.gitignore vendored
View File

@@ -223,6 +223,9 @@ dist/
uploads/* uploads/*
!uploads/.gitkeep !uploads/.gitkeep
# Worktrees
.worktrees/
# Claude Code # Claude Code
.claude/ .claude/

View File

@@ -9,7 +9,8 @@ GearBox is a single-user web app for managing gear collections (bikepacking, sim
## Commands ## Commands
```bash ```bash
# Development (run both in separate terminals) # Development
bun run dev # Starts both Vite client (:5173) and Hono server (:3000) concurrently
bun run dev:client # Vite dev server on :5173 (proxies /api to :3000) bun run dev:client # Vite dev server on :5173 (proxies /api to :3000)
bun run dev:server # Hono server on :3000 with hot reload bun run dev:server # Hono server on :3000 with hot reload
@@ -52,7 +53,7 @@ bun run build # Vite build → dist/client/
- **Schema**: `schema.ts` — Drizzle table definitions for SQLite. - **Schema**: `schema.ts` — Drizzle table definitions for SQLite.
- **Prices stored as cents** (`priceCents: integer`) to avoid float rounding. - **Prices stored as cents** (`priceCents: integer`) to avoid float rounding.
- **Timestamps**: stored as integers (unix epoch) with `{ mode: "timestamp" }`. - **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/`) ### Testing (`tests/`)
- Bun test runner. Tests at service level and route level. - Bun test runner. Tests at service level and route level.
@@ -67,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". - **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). - **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 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. - **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.

View File

@@ -10,7 +10,7 @@ A single-user web app for managing gear collections (bikepacking, sim racing, et
- Research threads for comparing candidates before buying - Research threads for comparing candidates before buying
- Image uploads for items and candidates - Image uploads for items and candidates
## Quick Start ## Quick Start (Docker)
### Docker Compose (recommended) ### Docker Compose (recommended)
@@ -81,3 +81,42 @@ docker compose up -d
``` ```
Database migrations run automatically on startup. Database migrations run automatically on startup.
## Tech Stack
- **Runtime & Package Manager:** [Bun](https://bun.sh)
- **Frontend:** React 19, Vite, TanStack Router, TanStack Query, Tailwind CSS v4, Zustand
- **Backend:** Hono, Drizzle ORM, SQLite (`bun:sqlite`)
## Local Development Setup
### Prerequisites
You must have [Bun](https://bun.sh/) installed on your machine. Docker is not required for local development.
### Installation
1. Install all dependencies:
```bash
bun install
```
2. Initialize the local SQLite database (`gearbox.db`):
```bash
bun run db:push
```
3. Start the development servers:
```bash
bun run dev
```
This single command will start both the Vite frontend server (port `5173`) and the Hono backend server (port `3000`) concurrently.
Open [http://localhost:5173](http://localhost:5173) in your browser to view the app.
## Additional Commands
- `bun run build` — Build the production assets into `dist/client/`
- `bun test` — Run the test suite
- `bun run lint` — Check formatting and lint rules using Biome
- `bun run db:generate` — Generate Drizzle migrations after making schema changes

View File

@@ -7,7 +7,7 @@
}, },
"files": { "files": {
"ignoreUnknown": false, "ignoreUnknown": false,
"includes": ["**", "!src/client/routeTree.gen.ts"] "includes": ["**", "!src/client/routeTree.gen.ts", "!drizzle", "!.planning"]
}, },
"formatter": { "formatter": {
"enabled": true, "enabled": true,

223
bun.lock
View File

@@ -6,11 +6,13 @@
"name": "gearbox", "name": "gearbox",
"dependencies": { "dependencies": {
"@hono/zod-validator": "^0.7.6", "@hono/zod-validator": "^0.7.6",
"@modelcontextprotocol/sdk": "^1.29.0",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"@tanstack/react-router": "^1.167.0", "@tanstack/react-router": "^1.167.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"framer-motion": "^12.38.0",
"hono": "^4.12.8", "hono": "^4.12.8",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"react": "^19.2.4", "react": "^19.2.4",
@@ -31,6 +33,7 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"better-sqlite3": "^12.8.0", "better-sqlite3": "^12.8.0",
"concurrently": "^9.1.2",
"drizzle-kit": "^0.31.9", "drizzle-kit": "^0.31.9",
"vite": "^8.0.0", "vite": "^8.0.0",
}, },
@@ -160,6 +163,8 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], "@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=="], "@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=="], "@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=="],
@@ -172,6 +177,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=="], "@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=="], "@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=="], "@oxc-project/runtime": ["@oxc-project/runtime@0.115.0", "", {}, "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ=="],
@@ -310,8 +317,18 @@
"@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=="], "@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=="], "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=="],
"ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="], "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="],
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
@@ -332,6 +349,8 @@
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], "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=="], "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=="], "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=="],
@@ -342,18 +361,46 @@
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], "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=="], "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=="],
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"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=="], "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-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=="], "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=="], "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
@@ -386,6 +433,8 @@
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], "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=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
@@ -394,12 +443,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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="],
@@ -408,24 +471,56 @@
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="],
"framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], "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=="], "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=="], "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=="], "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=="], "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
@@ -434,10 +529,22 @@
"goober": ["goober@2.1.18", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw=="], "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=="], "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=="], "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=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="], "immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
@@ -448,22 +555,38 @@
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], "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-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=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], "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=="], "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=="], "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=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "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=="], "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=="], "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=="],
@@ -496,40 +619,78 @@
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "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=="], "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
"motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="],
"motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], "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-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=="], "node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], "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=="], "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=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
@@ -552,22 +713,52 @@
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
"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=="], "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "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=="], "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=="], "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=="], "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=="], "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": ["seroval@1.5.1", "", {}, "sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA=="],
"seroval-plugins": ["seroval-plugins@1.5.1", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw=="], "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-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=="], "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=="],
@@ -578,10 +769,18 @@
"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=="], "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=="], "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
@@ -598,16 +797,24 @@
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "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=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], "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=="], "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=="], "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=="], "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=="], "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=="],
@@ -616,18 +823,32 @@
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "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=="], "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=="], "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=="], "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=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "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=="], "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=="], "@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=="],
@@ -654,6 +875,8 @@
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"node-abi/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "node-abi/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],

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.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,404 @@
# Image URL Fetching Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a `POST /api/images/from-url` endpoint that fetches an image from a URL, saves it locally, and returns the filename. Also add `imageSourceUrl` column to items and candidates.
**Architecture:** New image service function handles URL fetching with validation (content-type, size, timeout). New route delegates to service. Schema changes add nullable `imageSourceUrl` to items and threadCandidates tables. Drizzle migration for the new column.
**Tech Stack:** Hono routes, Zod validation, Drizzle ORM, Bun's native fetch, Bun's file I/O
---
## File Structure
| Action | Path | Responsibility |
|--------|------|----------------|
| Create | `src/server/services/image.service.ts` | Image fetching logic (fetch URL, validate, save to disk) |
| Modify | `src/server/routes/images.ts` | Add `POST /from-url` route |
| Modify | `src/db/schema.ts` | Add `imageSourceUrl` to `items` and `threadCandidates` |
| Modify | `src/shared/schemas.ts` | Add `imageSourceUrl` to item/candidate Zod schemas |
| Modify | `src/server/services/item.service.ts` | Pass through `imageSourceUrl` in create/update |
| Modify | `src/server/services/thread.service.ts` | Pass through `imageSourceUrl` in candidate create/update and thread resolution |
| Modify | `tests/helpers/db.ts` | Add `image_source_url` column to test CREATE TABLE statements |
| Create | `tests/services/image.service.test.ts` | Tests for image fetching service |
| Create | `tests/routes/images.test.ts` | Route-level tests for `/api/images/from-url` |
---
### Task 1: Add `imageSourceUrl` Column to Database Schema
**Files:**
- Modify: `src/db/schema.ts:12-29` (items table)
- Modify: `src/db/schema.ts:47-71` (threadCandidates table)
- Modify: `tests/helpers/db.ts:19-32` (items CREATE TABLE)
- Modify: `tests/helpers/db.ts:46-64` (thread_candidates CREATE TABLE)
- [ ] **Step 1: Add column to Drizzle items table**
In `src/db/schema.ts`, add after the `imageFilename` line in the `items` table:
```typescript
imageSourceUrl: text("image_source_url"),
```
- [ ] **Step 2: Add column to Drizzle threadCandidates table**
In `src/db/schema.ts`, add after the `imageFilename` line in the `threadCandidates` table:
```typescript
imageSourceUrl: text("image_source_url"),
```
- [ ] **Step 3: Update test helper — items table**
In `tests/helpers/db.ts`, add to the items CREATE TABLE statement after `image_filename TEXT,`:
```sql
image_source_url TEXT,
```
- [ ] **Step 4: Update test helper — thread_candidates table**
In `tests/helpers/db.ts`, add to the thread_candidates CREATE TABLE statement after `image_filename TEXT,`:
```sql
image_source_url TEXT,
```
- [ ] **Step 5: Generate Drizzle migration**
Run: `bun run db:generate`
Expected: A new migration file created in `drizzle/` adding `image_source_url` to both tables.
- [ ] **Step 6: Apply migration**
Run: `bun run db:push`
Expected: Migration applied successfully.
- [ ] **Step 7: Run existing tests to verify no regressions**
Run: `bun test`
Expected: All existing tests pass.
- [ ] **Step 8: Commit**
```bash
git add src/db/schema.ts tests/helpers/db.ts drizzle/
git commit -m "feat: add imageSourceUrl column to items and threadCandidates"
```
---
### Task 2: Update Zod Schemas and Service Functions
**Files:**
- Modify: `src/shared/schemas.ts:1-16` (item schemas)
- Modify: `src/shared/schemas.ts:47-60` (candidate schemas)
- Modify: `src/server/services/item.service.ts:50-71` (createItem)
- Modify: `src/server/services/item.service.ts:73-101` (updateItem)
- [ ] **Step 1: Add imageSourceUrl to createItemSchema**
In `src/shared/schemas.ts`, add after the `imageFilename` line in `createItemSchema`:
```typescript
imageSourceUrl: z.string().url().optional().or(z.literal("")),
```
- [ ] **Step 2: Add imageSourceUrl to createCandidateSchema**
In `src/shared/schemas.ts`, add after the `imageFilename` line in `createCandidateSchema`:
```typescript
imageSourceUrl: z.string().url().optional().or(z.literal("")),
```
- [ ] **Step 3: Update item service createItem**
In `src/server/services/item.service.ts`, update the `createItem` function's `.values()` to include:
```typescript
imageSourceUrl: data.imageSourceUrl ?? null,
```
- [ ] **Step 4: Update item service updateItem**
In `src/server/services/item.service.ts`, add `imageSourceUrl: string` to the `Partial<{...}>` type in `updateItem`.
- [ ] **Step 5: Update item service getAllItems and getItemById**
Add `imageSourceUrl: items.imageSourceUrl` to the `.select()` objects in both `getAllItems` and `getItemById`.
- [ ] **Step 6: Update thread service candidate create/update**
In `src/server/services/thread.service.ts`, find the candidate create and update functions. Add `imageSourceUrl` passthrough in the same pattern as `imageFilename`. Also ensure that when resolving a thread (copying candidate data to a new item), `imageSourceUrl` is copied from the winning candidate.
- [ ] **Step 7: Run tests**
Run: `bun test`
Expected: All tests pass.
- [ ] **Step 8: Commit**
```bash
git add src/shared/schemas.ts src/server/services/item.service.ts src/server/services/thread.service.ts
git commit -m "feat: add imageSourceUrl to Zod schemas and service functions"
```
---
### Task 3: Create Image Fetching Service
**Files:**
- Create: `src/server/services/image.service.ts`
- Create: `tests/services/image.service.test.ts`
- [ ] **Step 1: Write failing test — successful URL fetch**
Create `tests/services/image.service.test.ts`:
```typescript
import { afterEach, describe, expect, test } from "bun:test";
import { existsSync, rmSync } from "node:fs";
import { join } from "node:path";
import { fetchImageFromUrl } from "../../src/server/services/image.service";
const TEST_UPLOADS_DIR = "test-uploads";
afterEach(() => {
if (existsSync(TEST_UPLOADS_DIR)) {
rmSync(TEST_UPLOADS_DIR, { recursive: true });
}
});
describe("fetchImageFromUrl", () => {
test("fetches a valid image URL and saves to disk", async () => {
// Use a small, reliable test image
const url = "https://via.placeholder.com/10x10.png";
const result = await fetchImageFromUrl(url, TEST_UPLOADS_DIR);
expect(result.filename).toMatch(/^\d+-[\w-]+\.png$/);
expect(result.sourceUrl).toBe(url);
expect(existsSync(join(TEST_UPLOADS_DIR, result.filename))).toBe(true);
});
test("rejects non-image content type", async () => {
const url = "https://example.com/";
await expect(fetchImageFromUrl(url, TEST_UPLOADS_DIR)).rejects.toThrow(
"Invalid content type"
);
});
test("rejects invalid URL", async () => {
await expect(fetchImageFromUrl("not-a-url", TEST_UPLOADS_DIR)).rejects.toThrow();
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `bun test tests/services/image.service.test.ts`
Expected: FAIL — module not found.
- [ ] **Step 3: Implement image service**
Create `src/server/services/image.service.ts`:
```typescript
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 };
}
```
- [ ] **Step 4: Run tests**
Run: `bun test tests/services/image.service.test.ts`
Expected: All 3 tests pass.
- [ ] **Step 5: Commit**
```bash
git add src/server/services/image.service.ts tests/services/image.service.test.ts
git commit -m "feat: add image URL fetching service with tests"
```
---
### Task 4: Add Route for URL Image Fetching
**Files:**
- Modify: `src/server/routes/images.ts`
- Create: `tests/routes/images.test.ts`
- [ ] **Step 1: Write failing route test**
Create `tests/routes/images.test.ts`:
```typescript
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);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `bun test tests/routes/images.test.ts`
Expected: FAIL — route not found (404).
- [ ] **Step 3: Add the from-url route**
In `src/server/routes/images.ts`, add imports and the new route:
```typescript
import { randomUUID } from "node:crypto";
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
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 app = new Hono();
const fromUrlSchema = z.object({
url: z.string().url("Invalid URL"),
});
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) {
return c.json({ error: (err as Error).message }, 400);
}
});
// Existing file upload route stays below
app.post("/", async (c) => {
// ... existing code unchanged ...
});
```
Note: Keep the existing `app.post("/", ...)` handler exactly as-is. Just add the new `/from-url` route above it.
- [ ] **Step 4: Run tests**
Run: `bun test tests/routes/images.test.ts`
Expected: Both tests pass.
- [ ] **Step 5: Run all tests**
Run: `bun test`
Expected: All tests pass.
- [ ] **Step 6: Commit**
```bash
git add src/server/routes/images.ts tests/routes/images.test.ts
git commit -m "feat: add POST /api/images/from-url route"
```

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,133 @@
# Authentication — Design Spec
## Overview
Add authentication to GearBox with a public-read, authenticated-write model. Web UI uses cookie-based sessions. Programmatic access (MCP server, scripts) uses API keys. Single-user app — one admin account, created on first setup.
## Database Schema
### `users` table
```typescript
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(),
});
```
### `sessions` table
```typescript
export const sessions = sqliteTable("sessions", {
id: text("id").primaryKey(), // random token
userId: integer("user_id").notNull().references(() => users.id),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
});
```
### `apiKeys` table
```typescript
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(), // first 8 chars for identification
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
});
```
## Password Hashing
Use `Bun.password.hash()` and `Bun.password.verify()` with argon2 (Bun's default). No external dependencies needed.
## Auth Middleware
Hono middleware applied to all write endpoints (POST, PUT, DELETE):
1. Check for `X-API-Key` header — if present, hash and compare against `api_keys` table
2. Check for session cookie (`gearbox_session`) — if present, look up in `sessions` table, verify not expired
3. If neither is valid, return `401 Unauthorized`
GET endpoints remain public — no middleware applied.
**Exempt from auth:** `/api/auth/login`, `/api/auth/setup`, and `/api/auth/me` (GET) are not protected by the write middleware.
**Before setup:** If no user account exists yet, write endpoints return `403` with `{ error: "setup_required" }` so the frontend can prompt account creation.
## API Endpoints
### Auth routes (`/api/auth`)
- `POST /api/auth/login` — accepts `{ username, password }`, creates session, sets cookie
- `POST /api/auth/logout` — clears session cookie, deletes session record
- `GET /api/auth/me` — returns current user info if authenticated, or `null`
- `POST /api/auth/setup` — initial account creation (only works if no users exist)
- `PUT /api/auth/password` — change password (requires current password)
### API key routes (`/api/auth/keys`) — all authenticated
- `GET /api/auth/keys` — list API keys (name, prefix, createdAt — never the full key)
- `POST /api/auth/keys` — create new key, returns the full key once
- `DELETE /api/auth/keys/:id` — revoke a key
## Session Management
- Session token: 32-byte random hex string
- Cookie: `gearbox_session`, httpOnly, sameSite=lax, path=/
- Session expiry: 30 days, refreshed on each authenticated request
- Sessions stored in SQLite — simple cleanup of expired rows
## Frontend Changes
### Login button (top-right, Gitea-style)
- When not logged in: "Sign in" button in the header/navbar
- When logged in: username display with dropdown (logout, settings link)
### Login page
- Route: `/login`
- Simple form: username + password + submit
- Redirects back to previous page on success
- Error message on invalid credentials
### Initial setup
- If `GET /api/auth/me` returns `null` and no users exist, show a setup prompt
- Setup form: create username + password
- Only shown once — after account creation, normal login flow applies
### Conditional UI
- Add/edit/delete buttons: hidden when not authenticated
- Forms (ItemForm, CandidateForm, etc.): only accessible when authenticated
- The app is fully browseable without login — you just can't modify anything
### Auth state
- `useAuth` hook using React Query: calls `GET /api/auth/me`
- Returns `{ user, isAuthenticated, login, logout }`
- Cached and invalidated on login/logout
### Settings page additions
- API key management section: list, create, revoke
- Change password form
## Testing
- Auth service tests: login, logout, session creation/validation, password change
- API key tests: create, verify, revoke
- Middleware tests: write endpoints reject without auth, read endpoints work without auth
- Setup flow test: first-user creation
## Security Considerations
- Passwords hashed with argon2 via Bun built-in
- Session tokens are cryptographically random
- API keys hashed before storage (only shown once on creation)
- httpOnly cookies prevent XSS access to session
- No CORS changes needed (single-origin app)

View File

@@ -0,0 +1,87 @@
# Image URL Fetching — Design Spec
## Overview
Add the ability to fetch images from external URLs via the API, download them to local storage, and preserve the original source URL as metadata. API-only feature — no frontend changes.
## New Endpoint
### `POST /api/images/from-url`
**Request:**
```json
{ "url": "https://example.com/photo.jpg" }
```
**Validation (Zod):**
- `url` — valid URL string, required
**Server-side behavior:**
1. Fetch the URL with a 10-second timeout
2. Check response `Content-Type` is one of: `image/jpeg`, `image/png`, `image/webp`
3. Check `Content-Length` does not exceed 5MB (match existing upload limit)
4. Stream response body to `uploads/` directory using existing naming: `${Date.now()}-${randomUUID()}.${ext}`
5. If any check fails, return 400 with descriptive error
**Response:**
```json
{ "filename": "1712160000000-abc123.jpg", "sourceUrl": "https://example.com/photo.jpg" }
```
Callers can use `filename` for `imageFilename` and `sourceUrl` for `imageSourceUrl` when creating/updating items or candidates.
**Error responses:**
- `400` — invalid URL, unsupported content type, file too large, fetch failed
- `500` — server error during download/save
## Schema Changes
### `items` table
Add column:
```
imageSourceUrl: text("image_source_url") // nullable
```
### `threadCandidates` table
Add column:
```
imageSourceUrl: text("image_source_url") // nullable
```
### Zod schemas
Add `imageSourceUrl: z.string().url().optional()` to:
- `createItemSchema`
- `updateItemSchema`
- `createCandidateSchema`
- `updateCandidateSchema`
### Types
Types are inferred from Zod schemas and Drizzle tables — no manual updates needed.
## Existing Behavior Unchanged
- `POST /api/images` (file upload) remains as-is
- All existing image display, cleanup, and serving logic unchanged
- `imageFilename` continues to work identically
## Test Helper Updates
Add `image_source_url TEXT` column to the `items` and `thread_candidates` CREATE TABLE statements in `tests/helpers/db.ts`.
## Testing
- Service test: fetch from a valid URL, verify file saved and filename returned
- Route test: POST to `/api/images/from-url` with valid/invalid URLs
- Validation tests: wrong content type, oversized image, invalid URL format, timeout

View File

@@ -0,0 +1,158 @@
# MCP Server — Design Spec
## Overview
Built-in MCP server running inside the GearBox Hono process, exposed via SSE transport at `/mcp`. Provides tools for managing the full gear collection with workflow guidance emphasizing research threads. Authenticates via API key.
## Transport & Configuration
- **Transport:** SSE or Streamable HTTP at `/mcp` (use whichever the MCP SDK supports best at implementation time — the newer spec favors Streamable HTTP)
- **Enabled by default**, disable with `GEARBOX_MCP=false` env var
- **Authentication:** API key passed in MCP client config, sent as `X-API-Key` header
- **SDK:** `@modelcontextprotocol/sdk` TypeScript package
## Integration with Hono
The MCP server mounts as a route on the existing Hono app:
```typescript
// src/server/index.ts
if (process.env.GEARBOX_MCP !== "false") {
app.route("/mcp", mcpRoutes);
}
```
The MCP route handler bridges SSE transport to the MCP server instance, which calls GearBox services directly (not via HTTP) for efficiency.
## Tools
All tools include descriptive names and descriptions that guide Claude toward the research thread workflow.
### Item Tools
| Tool | Description |
|------|-------------|
| `list_items` | List all items in the gear collection. Optionally filter by category. |
| `get_item` | Get details of a specific item by ID. |
| `create_item` | 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. |
| `update_item` | Update an existing item's details. |
| `delete_item` | Remove an item from the collection. |
### Category Tools
| Tool | Description |
|------|-------------|
| `list_categories` | List all gear categories. |
| `create_category` | Create a new category for organizing gear. |
### Thread Tools (Primary Workflow)
| Tool | Description |
|------|-------------|
| `list_threads` | List all research threads. Threads are the recommended way to evaluate gear purchases — create a thread, add candidates, compare them, then resolve to pick a winner. |
| `get_thread` | Get a thread with all its candidates and comparison data. |
| `create_thread` | Start a new research thread for evaluating a gear purchase. This is the preferred workflow: create a thread describing what you need, add candidate products, compare specs/weight/price, then resolve when you've decided. |
| `resolve_thread` | Resolve a thread by picking the winning candidate. This adds the winner to your collection as a new item and marks the thread as resolved. |
### Candidate Tools
| Tool | Description |
|------|-------------|
| `add_candidate` | Add a candidate product to a research thread. Include weight, price, pros, cons, and optionally an image URL. |
| `update_candidate` | Update a candidate's details — weight, price, pros, cons, etc. |
| `remove_candidate` | Remove a candidate from a research thread. |
### Setup Tools
| Tool | Description |
|------|-------------|
| `list_setups` | List all gear setups (named configurations of items). |
| `get_setup` | Get a setup with all its items, total weight, and total cost. |
| `create_setup` | Create a new gear setup. |
| `update_setup` | Update a setup's items or details. |
### Image Tools
| Tool | Description |
|------|-------------|
| `upload_image_from_url` | Fetch an image from a URL and attach it to an item or candidate. |
## Resources
### `gearbox://collection-summary`
Provides an overview of the current gear collection:
- Total items, total weight, total cost
- Items per category
- Active research threads
- Number of setups
This resource gives Claude context about the collection state before making tool calls.
## Workflow Guidance
The MCP server's tool descriptions are crafted to guide Claude toward the research thread pattern:
1. When the user asks about buying gear, Claude should prefer `create_thread` over `create_item`
2. Candidates are added to threads for comparison before committing
3. `resolve_thread` is the way to finalize a purchase decision
4. Direct `create_item` is for items already owned or decided on
This guidance lives in the tool descriptions themselves — no separate system prompt needed. The `collection-summary` resource helps Claude understand what's already in the collection.
## Implementation Structure
```
src/server/mcp/
index.ts — MCP server setup, tool/resource registration
tools/
items.ts — Item tool handlers
categories.ts — Category tool handlers
threads.ts — Thread + candidate tool handlers
setups.ts — Setup tool handlers
images.ts — Image tool handlers
resources/
collection.ts — Collection summary resource
```
## Client Configuration
### Claude Code (`.claude/settings.json`)
```json
{
"mcpServers": {
"gearbox": {
"type": "sse",
"url": "http://localhost:3000/mcp",
"headers": {
"X-API-Key": "<your-api-key>"
}
}
}
}
```
### Claude Desktop
```json
{
"mcpServers": {
"gearbox": {
"type": "sse",
"url": "http://localhost:3000/mcp",
"headers": {
"X-API-Key": "<your-api-key>"
}
}
}
}
```
## Testing
- Tool handler tests: each tool with valid/invalid inputs
- Auth test: requests without API key are rejected
- Resource test: collection summary returns accurate data
- Integration test: create thread -> add candidates -> resolve -> verify item created

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, "when": 1773696058992,
"tag": "0005_clear_micromax", "tag": "0005_clear_micromax",
"breakpoints": true "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

@@ -4,6 +4,7 @@
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "concurrently -k -c \"blue,green\" -n \"server,client\" \"bun run dev:server\" \"bun run dev:client\"",
"dev:client": "vite", "dev:client": "vite",
"dev:server": "bun --hot src/server/index.ts", "dev:server": "bun --hot src/server/index.ts",
"build": "vite build", "build": "vite build",
@@ -23,6 +24,7 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"better-sqlite3": "^12.8.0", "better-sqlite3": "^12.8.0",
"concurrently": "^9.1.2",
"drizzle-kit": "^0.31.9", "drizzle-kit": "^0.31.9",
"vite": "^8.0.0" "vite": "^8.0.0"
}, },
@@ -31,11 +33,13 @@
}, },
"dependencies": { "dependencies": {
"@hono/zod-validator": "^0.7.6", "@hono/zod-validator": "^0.7.6",
"@modelcontextprotocol/sdk": "^1.29.0",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"@tanstack/react-router": "^1.167.0", "@tanstack/react-router": "^1.167.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"framer-motion": "^12.38.0",
"hono": "^4.12.8", "hono": "^4.12.8",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"react": "^19.2.4", "react": "^19.2.4",

View File

@@ -1,4 +1,5 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { useAuth, useLogout } from "../hooks/useAuth";
import { useUpdateSetting } from "../hooks/useSettings"; import { useUpdateSetting } from "../hooks/useSettings";
import { useTotals } from "../hooks/useTotals"; import { useTotals } from "../hooks/useTotals";
import { useWeightUnit } from "../hooks/useWeightUnit"; import { useWeightUnit } from "../hooks/useWeightUnit";
@@ -19,6 +20,9 @@ export function TotalsBar({
linkTo, linkTo,
}: TotalsBarProps) { }: TotalsBarProps) {
const { data } = useTotals(); const { data } = useTotals();
const { data: auth } = useAuth();
const logout = useLogout();
const isAuthenticated = !!auth?.user;
const unit = useWeightUnit(); const unit = useWeightUnit();
const updateSetting = useUpdateSetting(); const updateSetting = useUpdateSetting();
@@ -100,6 +104,24 @@ export function TotalsBar({
))} ))}
</div> </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> </div>
</div> </div>

View File

@@ -264,12 +264,20 @@ export function WeightSummaryCard({ items }: WeightSummaryCardProps) {
label="Consumable" label="Consumable"
weight={consumableWeight} weight={consumableWeight}
unit={unit} unit={unit}
percent={totalWeight > 0 ? consumableWeight / totalWeight : undefined} percent={
totalWeight > 0 ? consumableWeight / totalWeight : undefined
}
/> />
<div className="border-t border-gray-200 mt-1.5 pt-1.5"> <div className="border-t border-gray-200 mt-1.5 pt-1.5">
<div className="flex items-center gap-3 py-1.5"> <div className="flex items-center gap-3 py-1.5">
<LucideIcon name="sigma" size={10} className="text-gray-400 shrink-0 ml-0.5" /> <LucideIcon
<span className="text-sm font-medium text-gray-700 flex-1">Total</span> name="sigma"
size={10}
className="text-gray-400 shrink-0 ml-0.5"
/>
<span className="text-sm font-medium text-gray-700 flex-1">
Total
</span>
<span className="text-sm font-bold text-gray-900 tabular-nums"> <span className="text-sm font-bold text-gray-900 tabular-nums">
{formatWeight(totalWeight, unit)} {formatWeight(totalWeight, unit)}
</span> </span>

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

@@ -113,7 +113,10 @@ export function useUpdateItemClassification(setupId: number) {
mutationFn: ({ mutationFn: ({
itemId, itemId,
classification, classification,
}: { itemId: number; classification: string }) => }: {
itemId: number;
classification: string;
}) =>
apiPatch<{ success: boolean }>( apiPatch<{ success: boolean }>(
`/api/setups/${setupId}/items/${itemId}/classification`, `/api/setups/${setupId}/items/${itemId}/classification`,
{ classification }, { classification },

View File

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

View File

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

View File

@@ -9,11 +9,11 @@ import { ItemCard } from "../../components/ItemCard";
import { SetupCard } from "../../components/SetupCard"; import { SetupCard } from "../../components/SetupCard";
import { ThreadCard } from "../../components/ThreadCard"; import { ThreadCard } from "../../components/ThreadCard";
import { useCategories } from "../../hooks/useCategories"; import { useCategories } from "../../hooks/useCategories";
import { useCurrency } from "../../hooks/useCurrency";
import { useItems } from "../../hooks/useItems"; import { useItems } from "../../hooks/useItems";
import { useCreateSetup, useSetups } from "../../hooks/useSetups"; import { useCreateSetup, useSetups } from "../../hooks/useSetups";
import { useThreads } from "../../hooks/useThreads"; import { useThreads } from "../../hooks/useThreads";
import { useTotals } from "../../hooks/useTotals"; import { useTotals } from "../../hooks/useTotals";
import { useCurrency } from "../../hooks/useCurrency";
import { useWeightUnit } from "../../hooks/useWeightUnit"; import { useWeightUnit } from "../../hooks/useWeightUnit";
import { formatPrice, formatWeight } from "../../lib/formatters"; import { formatPrice, formatWeight } from "../../lib/formatters";
import { LucideIcon } from "../../lib/iconData"; import { LucideIcon } from "../../lib/iconData";

View File

@@ -1,10 +1,10 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { DashboardCard } from "../components/DashboardCard"; import { DashboardCard } from "../components/DashboardCard";
import { useCurrency } from "../hooks/useCurrency";
import { useSetups } from "../hooks/useSetups"; import { useSetups } from "../hooks/useSetups";
import { useThreads } from "../hooks/useThreads"; import { useThreads } from "../hooks/useThreads";
import { useTotals } from "../hooks/useTotals"; import { useTotals } from "../hooks/useTotals";
import { useWeightUnit } from "../hooks/useWeightUnit"; import { useWeightUnit } from "../hooks/useWeightUnit";
import { useCurrency } from "../hooks/useCurrency";
import { formatPrice, formatWeight } from "../lib/formatters"; import { formatPrice, formatWeight } from "../lib/formatters";
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/")({
@@ -35,7 +35,10 @@ function DashboardPage() {
label: "Weight", label: "Weight",
value: formatWeight(global?.totalWeight ?? null, unit), value: formatWeight(global?.totalWeight ?? null, unit),
}, },
{ label: "Cost", value: formatPrice(global?.totalCost ?? null, currency) }, {
label: "Cost",
value: formatPrice(global?.totalCost ?? null, currency),
},
]} ]}
emptyText="Get started" emptyText="Get started"
/> />

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 { 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 { useCurrency } from "../hooks/useCurrency";
import { useUpdateSetting } from "../hooks/useSettings"; import { useUpdateSetting } from "../hooks/useSettings";
import { useWeightUnit } from "../hooks/useWeightUnit"; import { useWeightUnit } from "../hooks/useWeightUnit";
@@ -18,10 +26,157 @@ export const Route = createFileRoute("/settings")({
component: SettingsPage, 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() { function SettingsPage() {
const unit = useWeightUnit(); const unit = useWeightUnit();
const currency = useCurrency(); const currency = useCurrency();
const updateSetting = useUpdateSetting(); const updateSetting = useUpdateSetting();
const { data: auth } = useAuth();
return ( return (
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <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> </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> </div>
); );
} }

View File

@@ -4,13 +4,13 @@ import { CategoryHeader } from "../../components/CategoryHeader";
import { ItemCard } from "../../components/ItemCard"; import { ItemCard } from "../../components/ItemCard";
import { ItemPicker } from "../../components/ItemPicker"; import { ItemPicker } from "../../components/ItemPicker";
import { WeightSummaryCard } from "../../components/WeightSummaryCard"; import { WeightSummaryCard } from "../../components/WeightSummaryCard";
import { useCurrency } from "../../hooks/useCurrency";
import { import {
useDeleteSetup, useDeleteSetup,
useRemoveSetupItem, useRemoveSetupItem,
useSetup, useSetup,
useUpdateItemClassification, useUpdateItemClassification,
} from "../../hooks/useSetups"; } from "../../hooks/useSetups";
import { useCurrency } from "../../hooks/useCurrency";
import { useWeightUnit } from "../../hooks/useWeightUnit"; import { useWeightUnit } from "../../hooks/useWeightUnit";
import { formatPrice, formatWeight } from "../../lib/formatters"; import { formatPrice, formatWeight } from "../../lib/formatters";
import { LucideIcon } from "../../lib/iconData"; import { LucideIcon } from "../../lib/iconData";

View File

@@ -20,6 +20,7 @@ export const items = sqliteTable("items", {
notes: text("notes"), notes: text("notes"),
productUrl: text("product_url"), productUrl: text("product_url"),
imageFilename: text("image_filename"), imageFilename: text("image_filename"),
imageSourceUrl: text("image_source_url"),
createdAt: integer("created_at", { mode: "timestamp" }) createdAt: integer("created_at", { mode: "timestamp" })
.notNull() .notNull()
.$defaultFn(() => new Date()), .$defaultFn(() => new Date()),
@@ -58,6 +59,7 @@ export const threadCandidates = sqliteTable("thread_candidates", {
notes: text("notes"), notes: text("notes"),
productUrl: text("product_url"), productUrl: text("product_url"),
imageFilename: text("image_filename"), imageFilename: text("image_filename"),
imageSourceUrl: text("image_source_url"),
status: text("status").notNull().default("researching"), status: text("status").notNull().default("researching"),
pros: text("pros"), pros: text("pros"),
cons: text("cons"), cons: text("cons"),
@@ -96,3 +98,30 @@ export const settings = sqliteTable("settings", {
key: text("key").primaryKey(), key: text("key").primaryKey(),
value: text("value").notNull(), 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 { Hono } from "hono";
import { serveStatic } from "hono/bun"; import { serveStatic } from "hono/bun";
import { seedDefaults } from "../db/seed.ts"; 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 { categoryRoutes } from "./routes/categories.ts";
import { imageRoutes } from "./routes/images.ts"; import { imageRoutes } from "./routes/images.ts";
import { itemRoutes } from "./routes/items.ts"; import { itemRoutes } from "./routes/items.ts";
@@ -19,7 +22,18 @@ app.get("/api/health", (c) => {
return c.json({ status: "ok" }); 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 // API routes
app.route("/api/auth", authRoutes);
app.route("/api/items", itemRoutes); app.route("/api/items", itemRoutes);
app.route("/api/categories", categoryRoutes); app.route("/api/categories", categoryRoutes);
app.route("/api/totals", totalRoutes); app.route("/api/totals", totalRoutes);
@@ -28,6 +42,11 @@ app.route("/api/settings", settingsRoutes);
app.route("/api/threads", threadRoutes); app.route("/api/threads", threadRoutes);
app.route("/api/setups", setupRoutes); app.route("/api/setups", setupRoutes);
// MCP server (conditionally mounted)
if (process.env.GEARBOX_MCP !== "false") {
app.route("/mcp", mcpRoutes);
}
// Serve uploaded images // Serve uploaded images
app.use("/uploads/*", serveStatic({ root: "./" })); 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 { randomUUID } from "node:crypto";
import { mkdir } from "node:fs/promises"; import { mkdir } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono"; import { Hono } from "hono";
import { z } from "zod";
import { fetchImageFromUrl } from "../services/image.service";
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"]; const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB const MAX_SIZE = 5 * 1024 * 1024; // 5MB
const fromUrlSchema = z.object({ url: z.string().url("Invalid URL") });
const app = new Hono(); 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) => { app.post("/", async (c) => {
const body = await c.req.parseBody(); const body = await c.req.parseBody();
const file = body.image; 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, notes: items.notes,
productUrl: items.productUrl, productUrl: items.productUrl,
imageFilename: items.imageFilename, imageFilename: items.imageFilename,
imageSourceUrl: items.imageSourceUrl,
createdAt: items.createdAt, createdAt: items.createdAt,
updatedAt: items.updatedAt, updatedAt: items.updatedAt,
categoryName: categories.name, categoryName: categories.name,
@@ -38,6 +39,7 @@ export function getItemById(db: Db = prodDb, id: number) {
notes: items.notes, notes: items.notes,
productUrl: items.productUrl, productUrl: items.productUrl,
imageFilename: items.imageFilename, imageFilename: items.imageFilename,
imageSourceUrl: items.imageSourceUrl,
createdAt: items.createdAt, createdAt: items.createdAt,
updatedAt: items.updatedAt, updatedAt: items.updatedAt,
}) })
@@ -65,6 +67,7 @@ export function createItem(
notes: data.notes ?? null, notes: data.notes ?? null,
productUrl: data.productUrl ?? null, productUrl: data.productUrl ?? null,
imageFilename: data.imageFilename ?? null, imageFilename: data.imageFilename ?? null,
imageSourceUrl: data.imageSourceUrl ?? null,
}) })
.returning() .returning()
.get(); .get();
@@ -81,6 +84,7 @@ export function updateItem(
notes: string; notes: string;
productUrl: string; productUrl: string;
imageFilename: string; imageFilename: string;
imageSourceUrl: string;
}>, }>,
) { ) {
// Check if item exists first // Check if item exists first

View File

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

View File

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

View File

@@ -26,6 +26,7 @@ export function createTestDb() {
notes TEXT, notes TEXT,
product_url TEXT, product_url TEXT,
image_filename TEXT, image_filename TEXT,
image_source_url TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch()), created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch()) updated_at INTEGER NOT NULL DEFAULT (unixepoch())
) )
@@ -54,6 +55,7 @@ export function createTestDb() {
notes TEXT, notes TEXT,
product_url TEXT, product_url TEXT,
image_filename TEXT, image_filename TEXT,
image_source_url TEXT,
status TEXT NOT NULL DEFAULT 'researching', status TEXT NOT NULL DEFAULT 'researching',
pros TEXT, pros TEXT,
cons 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 }); const db = drizzle(sqlite, { schema });
// Seed default Uncategorized category // 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,89 @@
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
import { existsSync, rmSync } from "node:fs";
import type { Server } from "bun";
import { fetchImageFromUrl } from "../../src/server/services/image.service.ts";
const TEST_UPLOADS_DIR = "test-uploads";
// 1x1 transparent PNG (smallest valid PNG)
const TINY_PNG = new Uint8Array([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49,
0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06,
0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44,
0x41, 0x54, 0x78, 0x9c, 0x62, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21,
0xbc, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60,
0x82,
]);
let server: Server;
let baseUrl: string;
beforeAll(() => {
server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url);
if (url.pathname === "/image.png") {
return new Response(TINY_PNG, {
headers: { "Content-Type": "image/png" },
});
}
if (url.pathname === "/page.html") {
return new Response("<html></html>", {
headers: { "Content-Type": "text/html" },
});
}
return new Response("Not found", { status: 404 });
},
});
baseUrl = `http://localhost:${server.port}`;
});
afterAll(() => {
server.stop(true);
});
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 = `${baseUrl}/image.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(`${baseUrl}/page.html`, 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");
});
it("rejects 404 responses", async () => {
await expect(
fetchImageFromUrl(`${baseUrl}/missing.jpg`, TEST_UPLOADS_DIR),
).rejects.toThrow("HTTP 404");
});
});
});

View File

@@ -434,7 +434,10 @@ describe("Thread Service", () => {
}); });
it("returns { success: false, error } when thread status is 'resolved'", () => { it("returns { success: false, error } when thread status is 'resolved'", () => {
const thread = createThread(db, { name: "Resolved Thread", categoryId: 1 }); const thread = createThread(db, {
name: "Resolved Thread",
categoryId: 1,
});
const candidate = createCandidate(db, thread.id, { const candidate = createCandidate(db, thread.id, {
name: "Winner", name: "Winner",
categoryId: 1, categoryId: 1,