Compare commits
29 Commits
a826381981
...
v1.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ae41c4ffa | |||
| be168c8329 | |||
| 9191f0fe24 | |||
| e34a2cad11 | |||
| 790fc07f5a | |||
| 4148833644 | |||
| 17d76761bb | |||
| ba9662aeaf | |||
| 6f51432d42 | |||
| 8919829167 | |||
| a10156142f | |||
| 5bb728e545 | |||
| 511fece4c7 | |||
| 87a367d41b | |||
| 66dc8ec8ee | |||
| e0e7bfce3e | |||
| 8138458d8d | |||
| 7c4fa9d9d2 | |||
| 32c7b41ce5 | |||
| b3a13fa974 | |||
| 0004329895 | |||
| d104e9788f | |||
| 1eb4a786ce | |||
| 0998f65c6f | |||
| a6a4ffda2e | |||
| dde2fc241d | |||
| 32d6babf24 | |||
| 6fe029f531 | |||
| 725901623b |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -223,6 +223,9 @@ dist/
|
|||||||
uploads/*
|
uploads/*
|
||||||
!uploads/.gitkeep
|
!uploads/.gitkeep
|
||||||
|
|
||||||
|
# Worktrees
|
||||||
|
.worktrees/
|
||||||
|
|
||||||
# Claude Code
|
# Claude Code
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
|||||||
81
CLAUDE.md
81
CLAUDE.md
@@ -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.
|
||||||
41
README.md
41
README.md
@@ -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
|
||||||
@@ -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
223
bun.lock
@@ -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
642
docs/api.md
Normal 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
281
docs/authentication.md
Normal 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
253
docs/mcp-server.md
Normal 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.
|
||||||
1503
docs/superpowers/plans/2026-04-03-authentication.md
Normal file
1503
docs/superpowers/plans/2026-04-03-authentication.md
Normal file
File diff suppressed because it is too large
Load Diff
404
docs/superpowers/plans/2026-04-03-image-url-fetching.md
Normal file
404
docs/superpowers/plans/2026-04-03-image-url-fetching.md
Normal 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"
|
||||||
|
```
|
||||||
1223
docs/superpowers/plans/2026-04-03-mcp-server.md
Normal file
1223
docs/superpowers/plans/2026-04-03-mcp-server.md
Normal file
File diff suppressed because it is too large
Load Diff
133
docs/superpowers/specs/2026-04-03-authentication-design.md
Normal file
133
docs/superpowers/specs/2026-04-03-authentication-design.md
Normal 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)
|
||||||
@@ -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
|
||||||
158
docs/superpowers/specs/2026-04-03-mcp-server-design.md
Normal file
158
docs/superpowers/specs/2026-04-03-mcp-server-design.md
Normal 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
|
||||||
2
drizzle/0006_hard_the_professor.sql
Normal file
2
drizzle/0006_hard_the_professor.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `items` ADD `image_source_url` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `thread_candidates` ADD `image_source_url` text;
|
||||||
23
drizzle/0007_icy_prodigy.sql
Normal file
23
drizzle/0007_icy_prodigy.sql
Normal 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`);
|
||||||
519
drizzle/meta/0006_snapshot.json
Normal file
519
drizzle/meta/0006_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
655
drizzle/meta/0007_snapshot.json
Normal file
655
drizzle/meta/0007_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
97
src/client/hooks/useAuth.ts
Normal file
97
src/client/hooks/useAuth.ts
Normal 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"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
104
src/client/routes/login.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
174
src/server/mcp/index.ts
Normal 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);
|
||||||
|
});
|
||||||
41
src/server/mcp/resources/collection.ts
Normal file
41
src/server/mcp/resources/collection.ts
Normal 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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
72
src/server/mcp/tools/categories.ts
Normal file
72
src/server/mcp/tools/categories.ts
Normal 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
48
src/server/mcp/tools/images.ts
Normal file
48
src/server/mcp/tools/images.ts
Normal 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
190
src/server/mcp/tools/items.ts
Normal file
190
src/server/mcp/tools/items.ts
Normal 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
131
src/server/mcp/tools/setups.ts
Normal file
131
src/server/mcp/tools/setups.ts
Normal 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
252
src/server/mcp/tools/threads.ts
Normal file
252
src/server/mcp/tools/threads.ts
Normal 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
38
src/server/middleware/auth.ts
Normal file
38
src/server/middleware/auth.ts
Normal 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
194
src/server/routes/auth.ts
Normal 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;
|
||||||
@@ -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;
|
||||||
|
|||||||
158
src/server/services/auth.service.ts
Normal file
158
src/server/services/auth.service.ts
Normal 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();
|
||||||
|
}
|
||||||
82
src/server/services/image.service.ts
Normal file
82
src/server/services/image.service.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
253
tests/mcp/tools.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
86
tests/middleware/auth.test.ts
Normal file
86
tests/middleware/auth.test.ts
Normal 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
128
tests/routes/auth.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
26
tests/routes/images.test.ts
Normal file
26
tests/routes/images.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
171
tests/services/auth.service.test.ts
Normal file
171
tests/services/auth.service.test.ts
Normal 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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
89
tests/services/image.service.test.ts
Normal file
89
tests/services/image.service.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user