docs: add implementation plans for image URL fetching, auth, and MCP server
Three detailed implementation plans with TDD, exact code, and step-by-step tasks: - Image URL fetching: 4 tasks (schema, Zod, service, route) - Authentication: 9 tasks (tables, service, middleware, routes, frontend) - MCP server: 9 tasks (SDK, tools, resources, Hono integration) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
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
Reference in New Issue
Block a user