Files
GearBox/docs/superpowers/plans/2026-04-03-image-url-fetching.md
Jean-Luc Makiola a6a4ffda2e docs: add implementation plans for image URL fetching, auth, and MCP server
Three detailed implementation plans with TDD, exact code, and step-by-step tasks:
- Image URL fetching: 4 tasks (schema, Zod, service, route)
- Authentication: 9 tasks (tables, service, middleware, routes, frontend)
- MCP server: 9 tasks (SDK, tools, resources, Hono integration)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:06:46 +02:00

12 KiB

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:

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:

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,:

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,:

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
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:

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:

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:

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
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:

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:

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
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:

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:

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
git add src/server/routes/images.ts tests/routes/images.test.ts
git commit -m "feat: add POST /api/images/from-url route"