Files
GearBox/docs/superpowers/plans/2026-04-03-authentication.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

40 KiB

Authentication 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 public-read / authenticated-write auth to GearBox with cookie sessions for the web UI, API keys for programmatic access, and a Gitea-style login button.

Architecture: Three new DB tables (users, sessions, api_keys). Auth service handles password hashing (Bun.password/argon2), session management, and API key verification. Hono middleware guards write endpoints. Frontend gets a useAuth hook, login page, conditional UI for edit actions, and API key management in settings.

Tech Stack: Hono middleware, Bun.password (argon2), Drizzle ORM, React Query, TanStack Router, Zustand


File Structure

Action Path Responsibility
Modify src/db/schema.ts Add users, sessions, apiKeys tables
Modify tests/helpers/db.ts Add CREATE TABLE for users, sessions, api_keys
Create src/server/services/auth.service.ts Password hashing, session CRUD, API key CRUD
Create src/server/middleware/auth.ts Hono middleware for write endpoint protection
Create src/server/routes/auth.ts Login, logout, setup, password change, API key routes
Modify src/server/index.ts Register auth routes, apply auth middleware
Create src/client/hooks/useAuth.ts Auth state hook (React Query)
Create src/client/routes/login.tsx Login page
Modify src/client/routes/settings.tsx Add password change and API key management sections
Modify src/client/components/TotalsBar.tsx Add login/user button to top-right
Modify src/client/routes/__root.tsx Wrap conditional UI based on auth state
Create tests/services/auth.service.test.ts Auth service tests
Create tests/routes/auth.test.ts Auth route tests

Task 1: Add Auth Database Tables

Files:

  • Modify: src/db/schema.ts

  • Modify: tests/helpers/db.ts

  • Step 1: Add users table to Drizzle schema

In src/db/schema.ts, add after the settings table:

export const users = sqliteTable("users", {
	id: integer("id").primaryKey({ autoIncrement: true }),
	username: text("username").notNull().unique(),
	passwordHash: text("password_hash").notNull(),
	createdAt: integer("created_at", { mode: "timestamp" })
		.notNull()
		.$defaultFn(() => new Date()),
});
  • Step 2: Add sessions table to Drizzle schema

In src/db/schema.ts, add after the users table:

export const sessions = sqliteTable("sessions", {
	id: text("id").primaryKey(),
	userId: integer("user_id")
		.notNull()
		.references(() => users.id, { onDelete: "cascade" }),
	expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
});
  • Step 3: Add apiKeys table to Drizzle schema

In src/db/schema.ts, add after the sessions table:

export const apiKeys = sqliteTable("api_keys", {
	id: integer("id").primaryKey({ autoIncrement: true }),
	name: text("name").notNull(),
	keyHash: text("key_hash").notNull(),
	keyPrefix: text("key_prefix").notNull(),
	createdAt: integer("created_at", { mode: "timestamp" })
		.notNull()
		.$defaultFn(() => new Date()),
});
  • Step 4: Update test helper with CREATE TABLE statements

In tests/helpers/db.ts, add after the settings CREATE TABLE:

sqlite.run(`
  CREATE TABLE users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    username TEXT NOT NULL UNIQUE,
    password_hash TEXT NOT NULL,
    created_at INTEGER NOT NULL DEFAULT (unixepoch())
  )
`);

sqlite.run(`
  CREATE TABLE sessions (
    id TEXT PRIMARY KEY,
    user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    expires_at INTEGER NOT NULL
  )
`);

sqlite.run(`
  CREATE TABLE api_keys (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    key_hash TEXT NOT NULL,
    key_prefix TEXT NOT NULL,
    created_at INTEGER NOT NULL DEFAULT (unixepoch())
  )
`);
  • Step 5: Generate and apply migration

Run: bun run db:generate && bun run db:push Expected: Migration creates users, sessions, api_keys tables.

  • Step 6: Run existing tests

Run: bun test Expected: All existing tests still pass.

  • Step 7: Commit
git add src/db/schema.ts tests/helpers/db.ts drizzle/
git commit -m "feat: add users, sessions, and api_keys tables"

Task 2: Create Auth Service

Files:

  • Create: src/server/services/auth.service.ts

  • Create: tests/services/auth.service.test.ts

  • Step 1: Write failing tests for auth service

Create tests/services/auth.service.test.ts:

import { describe, expect, test, beforeEach } from "bun:test";
import { createTestDb } from "../helpers/db";
import {
	createUser,
	verifyPassword,
	createSession,
	getSession,
	deleteSession,
	createApiKey,
	verifyApiKey,
	listApiKeys,
	deleteApiKey,
	getUserCount,
	changePassword,
} from "../../src/server/services/auth.service";

let db: ReturnType<typeof createTestDb>;

beforeEach(() => {
	db = createTestDb();
});

describe("user management", () => {
	test("creates a user with hashed password", async () => {
		const user = await createUser(db, "admin", "password123");
		expect(user.username).toBe("admin");
		expect(user.passwordHash).not.toBe("password123");
	});

	test("verifies correct password", async () => {
		await createUser(db, "admin", "password123");
		const result = await verifyPassword(db, "admin", "password123");
		expect(result).not.toBeNull();
		expect(result!.username).toBe("admin");
	});

	test("rejects incorrect password", async () => {
		await createUser(db, "admin", "password123");
		const result = await verifyPassword(db, "admin", "wrongpassword");
		expect(result).toBeNull();
	});

	test("returns user count", async () => {
		expect(getUserCount(db)).toBe(0);
		await createUser(db, "admin", "password123");
		expect(getUserCount(db)).toBe(1);
	});

	test("changes password", async () => {
		await createUser(db, "admin", "old");
		const changed = await changePassword(db, "admin", "old", "new");
		expect(changed).toBe(true);
		const result = await verifyPassword(db, "admin", "new");
		expect(result).not.toBeNull();
	});

	test("rejects password change with wrong current password", async () => {
		await createUser(db, "admin", "old");
		const changed = await changePassword(db, "admin", "wrong", "new");
		expect(changed).toBe(false);
	});
});

describe("session management", () => {
	test("creates and retrieves a session", async () => {
		const user = await createUser(db, "admin", "pass");
		const session = createSession(db, user.id);
		expect(session.id).toHaveLength(64); // 32 bytes hex

		const found = getSession(db, session.id);
		expect(found).not.toBeNull();
		expect(found!.userId).toBe(user.id);
	});

	test("returns null for expired session", async () => {
		const user = await createUser(db, "admin", "pass");
		const session = createSession(db, user.id, -1); // expired 1 day ago
		const found = getSession(db, session.id);
		expect(found).toBeNull();
	});

	test("deletes a session", async () => {
		const user = await createUser(db, "admin", "pass");
		const session = createSession(db, user.id);
		deleteSession(db, session.id);
		expect(getSession(db, session.id)).toBeNull();
	});
});

describe("API key management", () => {
	test("creates an API key and returns the raw key once", async () => {
		const result = await createApiKey(db, "test-key");
		expect(result.name).toBe("test-key");
		expect(result.rawKey).toBeDefined();
		expect(result.rawKey.length).toBeGreaterThan(16);
		expect(result.prefix).toBe(result.rawKey.slice(0, 8));
	});

	test("verifies a valid API key", async () => {
		const result = await createApiKey(db, "test-key");
		const valid = await verifyApiKey(db, result.rawKey);
		expect(valid).toBe(true);
	});

	test("rejects an invalid API key", async () => {
		const valid = await verifyApiKey(db, "invalid-key");
		expect(valid).toBe(false);
	});

	test("lists API keys without exposing hashes", () => {
		// createApiKey is async, need to handle properly
	});

	test("deletes an API key", async () => {
		const result = await createApiKey(db, "test-key");
		deleteApiKey(db, result.id);
		const valid = await verifyApiKey(db, result.rawKey);
		expect(valid).toBe(false);
	});
});
  • Step 2: Run test to verify it fails

Run: bun test tests/services/auth.service.test.ts Expected: FAIL — module not found.

  • Step 3: Implement auth service

Create src/server/services/auth.service.ts:

import { randomBytes } from "node:crypto";
import { eq, count } from "drizzle-orm";
import { db as prodDb } from "../../db/index.ts";
import { users, sessions, apiKeys } from "../../db/schema.ts";

type Db = typeof prodDb;

// --- User Management ---

export async function createUser(db: Db = prodDb, username: string, password: string) {
	const passwordHash = await Bun.password.hash(password);
	return db
		.insert(users)
		.values({ username, passwordHash })
		.returning()
		.get();
}

export async function verifyPassword(db: Db = prodDb, username: string, password: string) {
	const user = db
		.select()
		.from(users)
		.where(eq(users.username, username))
		.get();

	if (!user) return null;

	const valid = await Bun.password.verify(password, user.passwordHash);
	return valid ? user : null;
}

export function getUserCount(db: Db = prodDb): number {
	const result = db.select({ count: count() }).from(users).get();
	return result?.count ?? 0;
}

export async function changePassword(
	db: Db = prodDb,
	username: string,
	currentPassword: string,
	newPassword: string,
): Promise<boolean> {
	const user = await verifyPassword(db, username, currentPassword);
	if (!user) return false;

	const passwordHash = await Bun.password.hash(newPassword);
	db.update(users)
		.set({ passwordHash })
		.where(eq(users.id, user.id))
		.run();
	return true;
}

// --- Session Management ---

export function createSession(db: Db = prodDb, userId: number, expiryDays = 30) {
	const id = randomBytes(32).toString("hex");
	const expiresAt = new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000);

	return db
		.insert(sessions)
		.values({ id, userId, expiresAt })
		.returning()
		.get();
}

export function getSession(db: Db = prodDb, sessionId: string) {
	const session = db
		.select()
		.from(sessions)
		.where(eq(sessions.id, sessionId))
		.get();

	if (!session) return null;

	// Check expiry
	if (session.expiresAt < new Date()) {
		db.delete(sessions).where(eq(sessions.id, sessionId)).run();
		return null;
	}

	return session;
}

export function deleteSession(db: Db = prodDb, sessionId: string) {
	db.delete(sessions).where(eq(sessions.id, sessionId)).run();
}

export function refreshSession(db: Db = prodDb, sessionId: string, expiryDays = 30) {
	const expiresAt = new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000);
	db.update(sessions)
		.set({ expiresAt })
		.where(eq(sessions.id, sessionId))
		.run();
}

// --- API Key Management ---

export async function createApiKey(db: Db = prodDb, name: string) {
	const rawKey = randomBytes(32).toString("hex");
	const keyHash = await Bun.password.hash(rawKey);
	const keyPrefix = rawKey.slice(0, 8);

	const record = db
		.insert(apiKeys)
		.values({ name, keyHash, keyPrefix })
		.returning()
		.get();

	return { ...record, rawKey };
}

export async function verifyApiKey(db: Db = prodDb, rawKey: string): Promise<boolean> {
	const prefix = rawKey.slice(0, 8);
	const candidates = db
		.select()
		.from(apiKeys)
		.where(eq(apiKeys.keyPrefix, prefix))
		.all();

	for (const candidate of candidates) {
		if (await Bun.password.verify(rawKey, candidate.keyHash)) {
			return true;
		}
	}
	return false;
}

export function listApiKeys(db: Db = prodDb) {
	return db
		.select({
			id: apiKeys.id,
			name: apiKeys.name,
			keyPrefix: apiKeys.keyPrefix,
			createdAt: apiKeys.createdAt,
		})
		.from(apiKeys)
		.all();
}

export function deleteApiKey(db: Db = prodDb, id: number) {
	db.delete(apiKeys).where(eq(apiKeys.id, id)).run();
}
  • Step 4: Run tests

Run: bun test tests/services/auth.service.test.ts Expected: All tests pass.

  • Step 5: Commit
git add src/server/services/auth.service.ts tests/services/auth.service.test.ts
git commit -m "feat: add auth service with user, session, and API key management"

Task 3: Create Auth Middleware

Files:

  • Create: src/server/middleware/auth.ts

  • Create: tests/middleware/auth.test.ts

  • Step 1: Write failing middleware tests

Create tests/middleware/auth.test.ts:

import { describe, expect, test, beforeEach } from "bun:test";
import { Hono } from "hono";
import { createTestDb } from "../helpers/db";
import { requireAuth } from "../../src/server/middleware/auth";
import { createUser, createSession, createApiKey } from "../../src/server/services/auth.service";

let db: ReturnType<typeof createTestDb>;

beforeEach(() => {
	db = createTestDb();
});

function createApp() {
	const app = new Hono<{ Variables: { db?: any } }>();
	app.use("*", async (c, next) => {
		c.set("db", db);
		await next();
	});

	// Public GET
	app.get("/items", (c) => c.json({ ok: true }));

	// Protected POST
	app.post("/items", requireAuth, (c) => c.json({ ok: true }));

	return app;
}

describe("auth middleware", () => {
	test("allows GET requests without auth", async () => {
		const app = createApp();
		const res = await app.request("/items");
		expect(res.status).toBe(200);
	});

	test("rejects POST without auth", async () => {
		const app = createApp();
		const res = await app.request("/items", { method: "POST" });
		expect(res.status).toBe(401);
	});

	test("allows POST with valid session cookie", async () => {
		const app = createApp();
		const user = await createUser(db, "admin", "pass");
		const session = createSession(db, user.id);

		const res = await app.request("/items", {
			method: "POST",
			headers: { Cookie: `gearbox_session=${session.id}` },
		});
		expect(res.status).toBe(200);
	});

	test("allows POST with valid API key", async () => {
		const app = createApp();
		const key = await createApiKey(db, "test");

		const res = await app.request("/items", {
			method: "POST",
			headers: { "X-API-Key": key.rawKey },
		});
		expect(res.status).toBe(200);
	});

	test("rejects POST with invalid API key", async () => {
		const app = createApp();
		const res = await app.request("/items", {
			method: "POST",
			headers: { "X-API-Key": "invalid" },
		});
		expect(res.status).toBe(401);
	});

	test("returns 403 setup_required when no users exist", async () => {
		const app = createApp();
		const res = await app.request("/items", { method: "POST" });
		// With no users, middleware should return 401 (or 403 setup_required)
		const body = await res.json();
		expect(res.status).toBe(403);
		expect(body.error).toBe("setup_required");
	});
});

Note: The last test ("setup_required") should only trigger when there are no users and no auth credentials are provided. Adjust the middleware logic: if getUserCount === 0, return 403 with setup_required. Otherwise return 401.

  • Step 2: Run test to verify it fails

Run: bun test tests/middleware/auth.test.ts Expected: FAIL — module not found.

  • Step 3: Implement auth middleware

Create src/server/middleware/auth.ts:

import { getCookie } from "hono/cookie";
import type { Context, Next } from "hono";
import { getSession, verifyApiKey, getUserCount, refreshSession } from "../services/auth.service";

export async function requireAuth(c: Context, next: Next) {
	const db = c.get("db");

	// Check if any users exist at all
	if (getUserCount(db) === 0) {
		return c.json({ error: "setup_required" }, 403);
	}

	// Check API key first
	const apiKey = c.req.header("X-API-Key");
	if (apiKey) {
		const valid = await verifyApiKey(db, apiKey);
		if (valid) return next();
		return c.json({ error: "Invalid API key" }, 401);
	}

	// Check session cookie
	const sessionId = getCookie(c, "gearbox_session");
	if (sessionId) {
		const session = getSession(db, sessionId);
		if (session) {
			// Refresh session expiry on use
			refreshSession(db, sessionId);
			return next();
		}
	}

	return c.json({ error: "Authentication required" }, 401);
}
  • Step 4: Run tests

Run: bun test tests/middleware/auth.test.ts Expected: All tests pass.

  • Step 5: Commit
git add src/server/middleware/auth.ts tests/middleware/auth.test.ts
git commit -m "feat: add auth middleware for write endpoint protection"

Task 4: Create Auth Routes

Files:

  • Create: src/server/routes/auth.ts

  • Create: tests/routes/auth.test.ts

  • Step 1: Write failing route tests

Create tests/routes/auth.test.ts:

import { describe, expect, test, beforeEach } from "bun:test";
import { Hono } from "hono";
import { createTestDb } from "../helpers/db";
import { authRoutes } from "../../src/server/routes/auth";

let db: ReturnType<typeof createTestDb>;

function createApp() {
	const app = new Hono<{ Variables: { db?: any } }>();
	app.use("*", async (c, next) => {
		c.set("db", db);
		await next();
	});
	app.route("/api/auth", authRoutes);
	return app;
}

beforeEach(() => {
	db = createTestDb();
});

describe("POST /api/auth/setup", () => {
	test("creates first user account", async () => {
		const app = createApp();
		const res = await app.request("/api/auth/setup", {
			method: "POST",
			headers: { "Content-Type": "application/json" },
			body: JSON.stringify({ username: "admin", password: "pass123" }),
		});
		expect(res.status).toBe(201);
		const body = await res.json();
		expect(body.username).toBe("admin");
	});

	test("rejects setup when user already exists", async () => {
		const app = createApp();
		// First setup
		await app.request("/api/auth/setup", {
			method: "POST",
			headers: { "Content-Type": "application/json" },
			body: JSON.stringify({ username: "admin", password: "pass123" }),
		});
		// Second attempt
		const res = await app.request("/api/auth/setup", {
			method: "POST",
			headers: { "Content-Type": "application/json" },
			body: JSON.stringify({ username: "admin2", password: "pass123" }),
		});
		expect(res.status).toBe(403);
	});
});

describe("POST /api/auth/login", () => {
	test("returns session cookie on valid login", async () => {
		const app = createApp();
		// Setup first
		await app.request("/api/auth/setup", {
			method: "POST",
			headers: { "Content-Type": "application/json" },
			body: JSON.stringify({ username: "admin", password: "pass123" }),
		});

		const res = await app.request("/api/auth/login", {
			method: "POST",
			headers: { "Content-Type": "application/json" },
			body: JSON.stringify({ username: "admin", password: "pass123" }),
		});
		expect(res.status).toBe(200);
		expect(res.headers.get("set-cookie")).toContain("gearbox_session");
	});

	test("rejects invalid credentials", async () => {
		const app = createApp();
		await app.request("/api/auth/setup", {
			method: "POST",
			headers: { "Content-Type": "application/json" },
			body: JSON.stringify({ username: "admin", password: "pass123" }),
		});

		const res = await app.request("/api/auth/login", {
			method: "POST",
			headers: { "Content-Type": "application/json" },
			body: JSON.stringify({ username: "admin", password: "wrong" }),
		});
		expect(res.status).toBe(401);
	});
});

describe("GET /api/auth/me", () => {
	test("returns null when not authenticated", async () => {
		const app = createApp();
		const res = await app.request("/api/auth/me");
		expect(res.status).toBe(200);
		const body = await res.json();
		expect(body.user).toBeNull();
		expect(body.setupRequired).toBe(true);
	});
});
  • Step 2: Run test to verify it fails

Run: bun test tests/routes/auth.test.ts Expected: FAIL — module not found.

  • Step 3: Implement auth routes

Create src/server/routes/auth.ts:

import { eq } from "drizzle-orm";
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { setCookie, deleteCookie, getCookie } from "hono/cookie";
import { z } from "zod";
import { users } from "../../db/schema.ts";
import {
	createUser,
	verifyPassword,
	createSession,
	getSession,
	deleteSession,
	getUserCount,
	changePassword,
	createApiKey,
	listApiKeys,
	deleteApiKey,
} from "../services/auth.service";
import { requireAuth } from "../middleware/auth";

type Env = { Variables: { db?: any } };

const app = new Hono<Env>();

const loginSchema = z.object({
	username: z.string().min(1),
	password: z.string().min(1),
});

const setupSchema = z.object({
	username: z.string().min(1, "Username is required"),
	password: z.string().min(6, "Password must be at least 6 characters"),
});

const changePasswordSchema = z.object({
	currentPassword: z.string().min(1),
	newPassword: z.string().min(6, "Password must be at least 6 characters"),
});

const createKeySchema = z.object({
	name: z.string().min(1, "Key name is required"),
});

// --- Public routes ---

app.get("/me", (c) => {
	const db = c.get("db");
	const setupRequired = getUserCount(db) === 0;

	const sessionId = getCookie(c, "gearbox_session");
	if (sessionId) {
		const session = getSession(db, sessionId);
		if (session) {
			// Get user info
			return c.json({ user: { id: session.userId }, setupRequired: false });
		}
	}

	return c.json({ user: null, setupRequired });
});

app.post("/setup", zValidator("json", setupSchema), async (c) => {
	const db = c.get("db");

	if (getUserCount(db) > 0) {
		return c.json({ error: "Account already exists" }, 403);
	}

	const { username, password } = c.req.valid("json");
	const user = await createUser(db, username, password);
	const session = createSession(db, user.id);

	setCookie(c, "gearbox_session", session.id, {
		httpOnly: true,
		sameSite: "Lax",
		path: "/",
		maxAge: 30 * 24 * 60 * 60, // 30 days
	});

	return c.json({ username: user.username }, 201);
});

app.post("/login", zValidator("json", loginSchema), async (c) => {
	const db = c.get("db");
	const { username, password } = c.req.valid("json");

	const user = await verifyPassword(db, username, password);
	if (!user) {
		return c.json({ error: "Invalid credentials" }, 401);
	}

	const session = createSession(db, user.id);

	setCookie(c, "gearbox_session", session.id, {
		httpOnly: true,
		sameSite: "Lax",
		path: "/",
		maxAge: 30 * 24 * 60 * 60,
	});

	return c.json({ username: user.username });
});

app.post("/logout", (c) => {
	const db = c.get("db");
	const sessionId = getCookie(c, "gearbox_session");
	if (sessionId) {
		deleteSession(db, sessionId);
	}
	deleteCookie(c, "gearbox_session", { path: "/" });
	return c.json({ success: true });
});

// --- Protected routes ---

app.put("/password", requireAuth, zValidator("json", changePasswordSchema), async (c) => {
	const db = c.get("db");
	const sessionId = getCookie(c, "gearbox_session");
	if (!sessionId) return c.json({ error: "Session required" }, 401);

	const session = getSession(db, sessionId);
	if (!session) return c.json({ error: "Invalid session" }, 401);

	const { currentPassword, newPassword } = c.req.valid("json");

	// Look up username from session's userId
	const userRecord = db.select().from(users).where(eq(users.id, session.userId)).get();
	if (!userRecord) return c.json({ error: "User not found" }, 401);

	const changed = await changePassword(db, userRecord.username, currentPassword, newPassword);
	if (!changed) return c.json({ error: "Current password is incorrect" }, 401);

	return c.json({ success: true });
});

// API Key management
app.get("/keys", requireAuth, (c) => {
	const db = c.get("db");
	return c.json(listApiKeys(db));
});

app.post("/keys", requireAuth, zValidator("json", createKeySchema), async (c) => {
	const db = c.get("db");
	const { name } = c.req.valid("json");
	const result = await createApiKey(db, name);
	return c.json({
		id: result.id,
		name: result.name,
		key: result.rawKey,
		prefix: result.keyPrefix,
	}, 201);
});

app.delete("/keys/:id", requireAuth, async (c) => {
	const db = c.get("db");
	const id = Number(c.req.param("id"));
	deleteApiKey(db, id);
	return c.json({ success: true });
});

export { app as authRoutes };
  • Step 4: Run tests

Run: bun test tests/routes/auth.test.ts Expected: All tests pass.

  • Step 5: Commit
git add src/server/routes/auth.ts tests/routes/auth.test.ts
git commit -m "feat: add auth routes for login, setup, and API key management"

Task 5: Register Auth Routes and Apply Middleware to Write Endpoints

Files:

  • Modify: src/server/index.ts

  • Step 1: Register auth routes in server index

In src/server/index.ts, add import:

import { authRoutes } from "./routes/auth.ts";

Add route registration after the other API routes:

app.route("/api/auth", authRoutes);
  • Step 2: Apply auth middleware to write endpoints

In src/server/index.ts, add import:

import { requireAuth } from "./middleware/auth.ts";

Add middleware that protects all non-GET API requests (except auth routes):

// Auth middleware for write operations (POST/PUT/DELETE) on non-auth routes
app.use("/api/*", async (c, next) => {
	// Skip auth routes — they handle their own auth
	if (c.req.path.startsWith("/api/auth")) return next();
	// Skip GET requests — read is public
	if (c.req.method === "GET") return next();
	// All other methods require auth
	return requireAuth(c, next);
});

This middleware must be registered before the API route registrations.

  • Step 3: Run all tests

Run: bun test Expected: Some existing POST/PUT/DELETE tests may now fail because they don't provide auth. The test db has no users, so middleware should return 403 setup_required. This is expected — we'll address this in Step 4.

  • Step 4: Update existing route tests to handle auth

Since the middleware checks getUserCount and returns 403 when no users exist, the existing route tests that do POST/PUT/DELETE will need to either:

  • Create a user and session in test setup, OR
  • The test apps already mount routes directly without the global middleware, so they should be unaffected.

Check which tests fail. If route tests mount routes directly (which they do — each test creates its own Hono app and mounts routes), the global middleware in index.ts won't apply to them. They should still pass.

Run: bun test Expected: All tests pass (the per-route test apps don't use the global middleware).

  • Step 5: Commit
git add src/server/index.ts
git commit -m "feat: register auth routes and apply write-protection middleware"

Task 6: Create Frontend Auth Hook and Login Page

Files:

  • Create: src/client/hooks/useAuth.ts

  • Create: src/client/routes/login.tsx

  • Step 1: Create useAuth hook

Create src/client/hooks/useAuth.ts:

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiGet, apiPost } from "../lib/api";

interface AuthState {
	user: { id: number } | null;
	setupRequired: boolean;
}

export function useAuth() {
	return useQuery({
		queryKey: ["auth"],
		queryFn: () => apiGet<AuthState>("/api/auth/me"),
		staleTime: 5 * 60 * 1000, // 5 minutes
	});
}

export function useLogin() {
	const queryClient = useQueryClient();
	return useMutation({
		mutationFn: (data: { username: string; password: string }) =>
			apiPost<{ username: string }>("/api/auth/login", data),
		onSuccess: () => {
			queryClient.invalidateQueries({ queryKey: ["auth"] });
		},
	});
}

export function useLogout() {
	const queryClient = useQueryClient();
	return useMutation({
		mutationFn: () => apiPost<{ success: boolean }>("/api/auth/logout", {}),
		onSuccess: () => {
			queryClient.invalidateQueries({ queryKey: ["auth"] });
		},
	});
}

export function useSetup() {
	const queryClient = useQueryClient();
	return useMutation({
		mutationFn: (data: { username: string; password: string }) =>
			apiPost<{ username: string }>("/api/auth/setup", data),
		onSuccess: () => {
			queryClient.invalidateQueries({ queryKey: ["auth"] });
		},
	});
}

export function useChangePassword() {
	return useMutation({
		mutationFn: (data: { currentPassword: string; newPassword: string }) =>
			apiPut<{ success: boolean }>("/api/auth/password", data),
	});
}

interface ApiKeyResponse {
	id: number;
	name: string;
	key: string;
	prefix: string;
}

interface ApiKeyListItem {
	id: number;
	name: string;
	keyPrefix: string;
	createdAt: string;
}

export function useApiKeys() {
	return useQuery({
		queryKey: ["apiKeys"],
		queryFn: () => apiGet<ApiKeyListItem[]>("/api/auth/keys"),
	});
}

export function useCreateApiKey() {
	const queryClient = useQueryClient();
	return useMutation({
		mutationFn: (data: { name: string }) =>
			apiPost<ApiKeyResponse>("/api/auth/keys", data),
		onSuccess: () => {
			queryClient.invalidateQueries({ queryKey: ["apiKeys"] });
		},
	});
}

export function useDeleteApiKey() {
	const queryClient = useQueryClient();
	return useMutation({
		mutationFn: (id: number) =>
			apiPost<{ success: boolean }>(`/api/auth/keys/${id}`, {}),
		onSuccess: () => {
			queryClient.invalidateQueries({ queryKey: ["apiKeys"] });
		},
	});
}

Note: useDeleteApiKey should use apiDelete not apiPost. Fix:

export function useDeleteApiKey() {
	const queryClient = useQueryClient();
	return useMutation({
		mutationFn: (id: number) =>
			apiDelete<{ success: boolean }>(`/api/auth/keys/${id}`),
		onSuccess: () => {
			queryClient.invalidateQueries({ queryKey: ["apiKeys"] });
		},
	});
}

Also import apiDelete:

import { apiGet, apiPost, apiPut, apiDelete } from "../lib/api";
  • Step 2: Create login page

Create src/client/routes/login.tsx:

import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { useAuth, useLogin, useSetup } from "../hooks/useAuth";

export const Route = createFileRoute("/login")({
	component: LoginPage,
});

function LoginPage() {
	const navigate = useNavigate();
	const { data: auth } = useAuth();
	const login = useLogin();
	const setup = useSetup();

	const [username, setUsername] = useState("");
	const [password, setPassword] = useState("");
	const [error, setError] = useState("");

	const isSetup = auth?.setupRequired ?? false;

	async function handleSubmit(e: React.FormEvent) {
		e.preventDefault();
		setError("");

		try {
			if (isSetup) {
				await setup.mutateAsync({ username, password });
			} else {
				await login.mutateAsync({ username, password });
			}
			navigate({ to: "/" });
		} catch (err) {
			setError((err as Error).message);
		}
	}

	const isPending = login.isPending || setup.isPending;

	return (
		<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
			<div className="w-full max-w-sm">
				<h1 className="text-xl font-semibold text-gray-900 text-center mb-6">
					{isSetup ? "Create Account" : "Sign In"}
				</h1>

				<form onSubmit={handleSubmit} className="bg-white rounded-xl border border-gray-100 p-6 space-y-4">
					{isSetup && (
						<p className="text-sm text-gray-500">
							Create your admin account to manage your gear collection.
						</p>
					)}

					<div>
						<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
							Username
						</label>
						<input
							id="username"
							type="text"
							value={username}
							onChange={(e) => setUsername(e.target.value)}
							required
							className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200"
						/>
					</div>

					<div>
						<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
							Password
						</label>
						<input
							id="password"
							type="password"
							value={password}
							onChange={(e) => setPassword(e.target.value)}
							required
							minLength={isSetup ? 6 : undefined}
							className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200"
						/>
					</div>

					{error && (
						<p className="text-sm text-red-600">{error}</p>
					)}

					<button
						type="submit"
						disabled={isPending}
						className="w-full py-2 px-4 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
					>
						{isPending ? "..." : isSetup ? "Create Account" : "Sign In"}
					</button>
				</form>
			</div>
		</div>
	);
}
  • Step 3: Commit
git add src/client/hooks/useAuth.ts src/client/routes/login.tsx
git commit -m "feat: add useAuth hook and login page"

Task 7: Add Login Button to TotalsBar and Conditional UI

Files:

  • Modify: src/client/components/TotalsBar.tsx

  • Modify: src/client/routes/__root.tsx

  • Step 1: Add login/user button to TotalsBar

In src/client/components/TotalsBar.tsx, add imports:

import { useAuth, useLogout } from "../hooks/useAuth";

Inside the TotalsBar component, add:

const { data: auth } = useAuth();
const logout = useLogout();
const isAuthenticated = !!auth?.user;

In the JSX return, add a login/user section at the right end of the bar. After the stats section and before the closing tag of the bar container, add:

<div className="flex items-center gap-2 ml-auto">
	{isAuthenticated ? (
		<button
			type="button"
			onClick={() => logout.mutate()}
			className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
		>
			Sign out
		</button>
	) : (
		<Link
			to="/login"
			className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
		>
			Sign in
		</Link>
	)}
</div>
  • Step 2: Hide FAB and edit actions when not authenticated

In src/client/routes/__root.tsx, add:

import { useAuth } from "../hooks/useAuth";

Inside RootLayout, add:

const { data: auth } = useAuth();
const isAuthenticated = !!auth?.user;

Update the FAB visibility condition:

{showFab && isAuthenticated && (
  • Step 3: Verify the app loads correctly

Run: bun run dev Check:

  • App loads without login wall

  • "Sign in" button appears top-right

  • FAB is hidden when not logged in

  • Clicking "Sign in" shows login page

  • After login, FAB appears and "Sign out" replaces "Sign in"

  • Step 4: Commit

git add src/client/components/TotalsBar.tsx src/client/routes/__root.tsx
git commit -m "feat: add login button to header and conditional edit UI"

Task 8: Add API Key Management and Password Change to Settings

Files:

  • Modify: src/client/routes/settings.tsx

  • Step 1: Add password change section to settings

In src/client/routes/settings.tsx, add imports:

import { useState } from "react";
import { useAuth, useChangePassword, useApiKeys, useCreateApiKey, useDeleteApiKey } from "../hooks/useAuth";

Add a ChangePasswordSection component:

function ChangePasswordSection() {
	const changePassword = useChangePassword();
	const [currentPassword, setCurrentPassword] = useState("");
	const [newPassword, setNewPassword] = useState("");
	const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);

	async function handleSubmit(e: React.FormEvent) {
		e.preventDefault();
		setMessage(null);
		try {
			await changePassword.mutateAsync({ currentPassword, newPassword });
			setMessage({ type: "success", text: "Password changed" });
			setCurrentPassword("");
			setNewPassword("");
		} catch (err) {
			setMessage({ type: "error", text: (err as Error).message });
		}
	}

	return (
		<form onSubmit={handleSubmit} className="space-y-3">
			<h3 className="text-sm font-medium text-gray-900">Change Password</h3>
			<input
				type="password"
				placeholder="Current password"
				value={currentPassword}
				onChange={(e) => setCurrentPassword(e.target.value)}
				required
				className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200"
			/>
			<input
				type="password"
				placeholder="New password"
				value={newPassword}
				onChange={(e) => setNewPassword(e.target.value)}
				required
				minLength={6}
				className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200"
			/>
			{message && (
				<p className={`text-sm ${message.type === "success" ? "text-green-600" : "text-red-600"}`}>
					{message.text}
				</p>
			)}
			<button
				type="submit"
				disabled={changePassword.isPending}
				className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
			>
				{changePassword.isPending ? "..." : "Change Password"}
			</button>
		</form>
	);
}
  • Step 2: Add API key management section

Add an ApiKeySection component:

function ApiKeySection() {
	const { data: keys } = useApiKeys();
	const createKey = useCreateApiKey();
	const deleteKey = useDeleteApiKey();
	const [name, setName] = useState("");
	const [newKey, setNewKey] = useState<string | null>(null);

	async function handleCreate(e: React.FormEvent) {
		e.preventDefault();
		const result = await createKey.mutateAsync({ name });
		setNewKey(result.key);
		setName("");
	}

	return (
		<div className="space-y-3">
			<h3 className="text-sm font-medium text-gray-900">API Keys</h3>
			<p className="text-xs text-gray-500">
				API keys allow programmatic access to GearBox (e.g., from Claude Desktop or scripts).
			</p>

			{newKey && (
				<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
					<p className="text-xs font-medium text-amber-800 mb-1">
						Copy this key now  it won't be shown again:
					</p>
					<code className="text-xs text-amber-900 break-all select-all">{newKey}</code>
					<button
						type="button"
						onClick={() => setNewKey(null)}
						className="mt-2 text-xs text-amber-700 hover:text-amber-900"
					>
						Dismiss
					</button>
				</div>
			)}

			<form onSubmit={handleCreate} className="flex gap-2">
				<input
					type="text"
					placeholder="Key name (e.g., claude-desktop)"
					value={name}
					onChange={(e) => setName(e.target.value)}
					required
					className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200"
				/>
				<button
					type="submit"
					disabled={createKey.isPending}
					className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
				>
					Create
				</button>
			</form>

			{keys && keys.length > 0 && (
				<div className="space-y-2">
					{keys.map((key) => (
						<div key={key.id} className="flex items-center justify-between py-2 px-3 bg-gray-50 rounded-lg">
							<div>
								<span className="text-sm text-gray-900">{key.name}</span>
								<span className="text-xs text-gray-400 ml-2">{key.keyPrefix}...</span>
							</div>
							<button
								type="button"
								onClick={() => deleteKey.mutate(key.id)}
								className="text-xs text-red-500 hover:text-red-700"
							>
								Revoke
							</button>
						</div>
					))}
				</div>
			)}
		</div>
	);
}
  • Step 3: Add sections to SettingsPage

In the SettingsPage component, add after the existing settings card, conditionally rendering the auth sections when logged in:

{auth?.user && (
	<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6 mt-4">
		<ChangePasswordSection />
		<div className="border-t border-gray-100" />
		<ApiKeySection />
	</div>
)}

Add the auth query at the top of SettingsPage:

const { data: auth } = useAuth();
  • Step 4: Commit
git add src/client/routes/settings.tsx
git commit -m "feat: add password change and API key management to settings"

Task 9: Run Full Test Suite and Manual Verification

Files: None (verification only)

  • Step 1: Run all tests

Run: bun test Expected: All tests pass.

  • Step 2: Run linter

Run: bun run lint Expected: No errors.

  • Step 3: Manual verification

Run: bun run dev

Verify:

  1. App loads — all pages viewable without login
  2. "Sign in" link in top-right of TotalsBar
  3. Click "Sign in" → shows setup form (first time) or login form
  4. Create account → redirected to home, "Sign out" appears
  5. FAB and edit actions visible when logged in
  6. FAB hidden when logged out
  7. Settings page shows password change + API keys when logged in
  8. Create API key → key displayed once
  9. POST/PUT/DELETE API calls return 401 without auth
  10. GET API calls work without auth