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

1504 lines
40 KiB
Markdown

# 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:
```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()
.$defaultFn(() => new Date()),
});
```
- [ ] **Step 2: Add sessions table to Drizzle schema**
In `src/db/schema.ts`, add after the `users` table:
```typescript
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:
```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(),
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:
```typescript
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**
```bash
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`:
```typescript
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`:
```typescript
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**
```bash
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`:
```typescript
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`:
```typescript
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**
```bash
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`:
```typescript
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`:
```typescript
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**
```bash
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:
```typescript
import { authRoutes } from "./routes/auth.ts";
```
Add route registration after the other API routes:
```typescript
app.route("/api/auth", authRoutes);
```
- [ ] **Step 2: Apply auth middleware to write endpoints**
In `src/server/index.ts`, add import:
```typescript
import { requireAuth } from "./middleware/auth.ts";
```
Add middleware that protects all non-GET API requests (except auth routes):
```typescript
// 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**
```bash
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`:
```typescript
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:
```typescript
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`:
```typescript
import { apiGet, apiPost, apiPut, apiDelete } from "../lib/api";
```
- [ ] **Step 2: Create login page**
Create `src/client/routes/login.tsx`:
```typescript
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**
```bash
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:
```typescript
import { useAuth, useLogout } from "../hooks/useAuth";
```
Inside the `TotalsBar` component, add:
```typescript
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:
```tsx
<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:
```typescript
import { useAuth } from "../hooks/useAuth";
```
Inside `RootLayout`, add:
```typescript
const { data: auth } = useAuth();
const isAuthenticated = !!auth?.user;
```
Update the FAB visibility condition:
```typescript
{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**
```bash
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:
```typescript
import { useState } from "react";
import { useAuth, useChangePassword, useApiKeys, useCreateApiKey, useDeleteApiKey } from "../hooks/useAuth";
```
Add a `ChangePasswordSection` component:
```typescript
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:
```typescript
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:
```tsx
{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`:
```typescript
const { data: auth } = useAuth();
```
- [ ] **Step 4: Commit**
```bash
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