docs: add authentication, API reference, and MCP server guides
Some checks failed
CI / ci (push) Failing after 11s
Some checks failed
CI / ci (push) Failing after 11s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
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.
|
||||||
Reference in New Issue
Block a user