Compare commits
3 Commits
32d6babf24
...
0998f65c6f
| Author | SHA1 | Date | |
|---|---|---|---|
| 0998f65c6f | |||
| a6a4ffda2e | |||
| dde2fc241d |
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/
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
Reference in New Issue
Block a user