From e78002208aad38ec0e50b561d580fdbccc873c59 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 5 Apr 2026 10:49:51 +0200 Subject: [PATCH 1/3] feat(16-03): wire userId from context into all route handlers - Extract userId via c.get('userId') in every route handler - Pass userId to all service function calls as second argument - Update settings routes to use composite key [userId, key] - Update Env type to include userId in Variables - Auth routes pass userId to API key management functions --- src/server/routes/auth.ts | 11 +++++++---- src/server/routes/categories.ts | 14 +++++++++----- src/server/routes/items.ts | 26 ++++++++++++++++--------- src/server/routes/settings.ts | 17 +++++++++++------ src/server/routes/setups.ts | 28 +++++++++++++++++---------- src/server/routes/threads.ts | 34 +++++++++++++++++++++------------ src/server/routes/totals.ts | 7 ++++--- 7 files changed, 88 insertions(+), 49 deletions(-) diff --git a/src/server/routes/auth.ts b/src/server/routes/auth.ts index 911a776..cfa31c6 100644 --- a/src/server/routes/auth.ts +++ b/src/server/routes/auth.ts @@ -10,7 +10,7 @@ import { listApiKeys, } from "../services/auth.service.ts"; -type Env = { Variables: { db?: any } }; +type Env = { Variables: { db?: any; userId?: number } }; const createKeySchema = z.object({ name: z.string().min(1) }); @@ -33,7 +33,8 @@ app.get("/me", async (c) => { app.get("/keys", requireAuth, async (c) => { const db = c.get("db"); - const keys = await listApiKeys(db); + const userId = c.get("userId")!; + const keys = await listApiKeys(db, userId); return c.json(keys); }); @@ -43,8 +44,9 @@ app.post( zValidator("json", createKeySchema), async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const { name } = c.req.valid("json"); - const result = await createApiKey(db, name); + const result = await createApiKey(db, userId, name); return c.json( { @@ -60,9 +62,10 @@ app.post( app.delete("/keys/:id", requireAuth, async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const id = parseId(c.req.param("id")); if (!id) return c.json({ error: "Invalid key ID" }, 400); - await deleteApiKey(db, id); + await deleteApiKey(db, userId, id); return c.json({ ok: true }); }); diff --git a/src/server/routes/categories.ts b/src/server/routes/categories.ts index cf8fae5..c292bd4 100644 --- a/src/server/routes/categories.ts +++ b/src/server/routes/categories.ts @@ -12,20 +12,22 @@ import { updateCategory, } from "../services/category.service.ts"; -type Env = { Variables: { db?: any } }; +type Env = { Variables: { db?: any; userId?: number } }; const app = new Hono(); app.get("/", async (c) => { const db = c.get("db"); - const cats = await getAllCategories(db); + const userId = c.get("userId")!; + const cats = await getAllCategories(db, userId); return c.json(cats); }); app.post("/", zValidator("json", createCategorySchema), async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const data = c.req.valid("json"); - const cat = await createCategory(db, data); + const cat = await createCategory(db, userId, data); return c.json(cat, 201); }); @@ -34,10 +36,11 @@ app.put( zValidator("json", updateCategorySchema.omit({ id: true })), async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const id = parseId(c.req.param("id")); if (!id) return c.json({ error: "Invalid category ID" }, 400); const data = c.req.valid("json"); - const cat = await updateCategory(db, id, data); + const cat = await updateCategory(db, userId, id, data); if (!cat) return c.json({ error: "Category not found" }, 404); return c.json(cat); }, @@ -45,9 +48,10 @@ app.put( app.delete("/:id", async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const id = parseId(c.req.param("id")); if (!id) return c.json({ error: "Invalid category ID" }, 400); - const result = await deleteCategory(db, id); + const result = await deleteCategory(db, userId, id); if (!result.success) { if (result.error === "Cannot delete the Uncategorized category") { diff --git a/src/server/routes/items.ts b/src/server/routes/items.ts index cdaf077..a2f4333 100644 --- a/src/server/routes/items.ts +++ b/src/server/routes/items.ts @@ -14,13 +14,14 @@ import { updateItem, } from "../services/item.service.ts"; -type Env = { Variables: { db?: any } }; +type Env = { Variables: { db?: any; userId?: number } }; const app = new Hono(); app.get("/export", async (c) => { const db = c.get("db"); - const csv = await exportItemsCsv(db); + const userId = c.get("userId")!; + const csv = await exportItemsCsv(db, userId); c.header("Content-Type", "text/csv"); c.header("Content-Disposition", 'attachment; filename="gearbox-export.csv"'); return c.body(csv); @@ -28,6 +29,7 @@ app.get("/export", async (c) => { app.post("/import", async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const body = await c.req.parseBody(); // Accept either "file" (direct) or "image" (via apiUpload helper) const file = body.file ?? body.image; @@ -35,29 +37,32 @@ app.post("/import", async (c) => { return c.json({ error: "No CSV file provided" }, 400); } const content = await file.text(); - const result = await importItemsCsv(db, content); + const result = await importItemsCsv(db, userId, content); return c.json(result); }); app.get("/", async (c) => { const db = c.get("db"); - const items = await getAllItems(db); + const userId = c.get("userId")!; + const items = await getAllItems(db, userId); return c.json(items); }); app.get("/:id", async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const id = parseId(c.req.param("id")); if (!id) return c.json({ error: "Invalid item ID" }, 400); - const item = await getItemById(db, id); + const item = await getItemById(db, userId, id); if (!item) return c.json({ error: "Item not found" }, 404); return c.json(item); }); app.post("/", zValidator("json", createItemSchema), async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const data = c.req.valid("json"); - const item = await createItem(db, data); + const item = await createItem(db, userId, data); return c.json(item, 201); }); @@ -66,10 +71,11 @@ app.put( zValidator("json", updateItemSchema.omit({ id: true })), async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const id = parseId(c.req.param("id")); if (!id) return c.json({ error: "Invalid item ID" }, 400); const data = c.req.valid("json"); - const item = await updateItem(db, id, data); + const item = await updateItem(db, userId, id, data); if (!item) return c.json({ error: "Item not found" }, 404); return c.json(item); }, @@ -77,18 +83,20 @@ app.put( app.post("/:id/duplicate", async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const id = parseId(c.req.param("id")); if (!id) return c.json({ error: "Invalid item ID" }, 400); - const newItem = await duplicateItem(db, id); + const newItem = await duplicateItem(db, userId, id); if (!newItem) return c.json({ error: "Item not found" }, 404); return c.json(newItem, 201); }); app.delete("/:id", async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const id = parseId(c.req.param("id")); if (!id) return c.json({ error: "Invalid item ID" }, 400); - const deleted = await deleteItem(db, id); + const deleted = await deleteItem(db, userId, id); if (!deleted) return c.json({ error: "Item not found" }, 404); // Clean up image file if exists diff --git a/src/server/routes/settings.ts b/src/server/routes/settings.ts index 83544fa..0988860 100644 --- a/src/server/routes/settings.ts +++ b/src/server/routes/settings.ts @@ -1,24 +1,26 @@ -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { Hono } from "hono"; import { settings } from "../../db/schema.ts"; -type Env = { Variables: { db?: any } }; +type Env = { Variables: { db?: any; userId?: number } }; const app = new Hono(); app.get("/:key", async (c) => { const database = c.get("db"); + const userId = c.get("userId")!; const key = c.req.param("key"); const [row] = await database .select() .from(settings) - .where(eq(settings.key, key)); + .where(and(eq(settings.userId, userId), eq(settings.key, key))); if (!row) return c.json({ error: "Setting not found" }, 404); return c.json(row); }); app.put("/:key", async (c) => { const database = c.get("db"); + const userId = c.get("userId")!; const key = c.req.param("key"); const body = await c.req.json<{ value: string }>(); @@ -28,13 +30,16 @@ app.put("/:key", async (c) => { await database .insert(settings) - .values({ key, value: body.value }) - .onConflictDoUpdate({ target: settings.key, set: { value: body.value } }); + .values({ userId, key, value: body.value }) + .onConflictDoUpdate({ + target: [settings.userId, settings.key], + set: { value: body.value }, + }); const [row] = await database .select() .from(settings) - .where(eq(settings.key, key)); + .where(and(eq(settings.userId, userId), eq(settings.key, key))); return c.json(row); }); diff --git a/src/server/routes/setups.ts b/src/server/routes/setups.ts index 02ac006..eb40cb2 100644 --- a/src/server/routes/setups.ts +++ b/src/server/routes/setups.ts @@ -18,7 +18,7 @@ import { updateSetup, } from "../services/setup.service.ts"; -type Env = { Variables: { db?: any } }; +type Env = { Variables: { db?: any; userId?: number } }; const app = new Hono(); @@ -26,41 +26,46 @@ const app = new Hono(); app.get("/", async (c) => { const db = c.get("db"); - const setups = await getAllSetups(db); + const userId = c.get("userId")!; + const setups = await getAllSetups(db, userId); return c.json(setups); }); app.post("/", zValidator("json", createSetupSchema), async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const data = c.req.valid("json"); - const setup = await createSetup(db, data); + const setup = await createSetup(db, userId, data); return c.json(setup, 201); }); app.get("/:id", async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const id = parseId(c.req.param("id")); if (!id) return c.json({ error: "Invalid setup ID" }, 400); - const setup = await getSetupWithItems(db, id); + const setup = await getSetupWithItems(db, userId, id); if (!setup) return c.json({ error: "Setup not found" }, 404); return c.json(setup); }); app.put("/:id", zValidator("json", updateSetupSchema), async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const id = parseId(c.req.param("id")); if (!id) return c.json({ error: "Invalid setup ID" }, 400); const data = c.req.valid("json"); - const setup = await updateSetup(db, id, data); + const setup = await updateSetup(db, userId, id, data); if (!setup) return c.json({ error: "Setup not found" }, 404); return c.json(setup); }); app.delete("/:id", async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const id = parseId(c.req.param("id")); if (!id) return c.json({ error: "Invalid setup ID" }, 400); - const deleted = await deleteSetup(db, id); + const deleted = await deleteSetup(db, userId, id); if (!deleted) return c.json({ error: "Setup not found" }, 404); return c.json({ success: true }); }); @@ -69,14 +74,15 @@ app.delete("/:id", async (c) => { app.put("/:id/items", zValidator("json", syncSetupItemsSchema), async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const id = parseId(c.req.param("id")); if (!id) return c.json({ error: "Invalid setup ID" }, 400); const { itemIds } = c.req.valid("json"); - const setup = await getSetupWithItems(db, id); + const setup = await getSetupWithItems(db, userId, id); if (!setup) return c.json({ error: "Setup not found" }, 404); - await syncSetupItems(db, id, itemIds); + await syncSetupItems(db, userId, id, itemIds); return c.json({ success: true }); }); @@ -85,21 +91,23 @@ app.patch( zValidator("json", updateClassificationSchema), async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const setupId = parseId(c.req.param("id")); const itemId = parseId(c.req.param("itemId")); if (!setupId || !itemId) return c.json({ error: "Invalid ID" }, 400); const { classification } = c.req.valid("json"); - await updateItemClassification(db, setupId, itemId, classification); + await updateItemClassification(db, userId, setupId, itemId, classification); return c.json({ success: true }); }, ); app.delete("/:id/items/:itemId", async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const setupId = parseId(c.req.param("id")); const itemId = parseId(c.req.param("itemId")); if (!setupId || !itemId) return c.json({ error: "Invalid ID" }, 400); - await removeSetupItem(db, setupId, itemId); + await removeSetupItem(db, userId, setupId, itemId); return c.json({ success: true }); }); diff --git a/src/server/routes/threads.ts b/src/server/routes/threads.ts index 621c936..4d3a761 100644 --- a/src/server/routes/threads.ts +++ b/src/server/routes/threads.ts @@ -24,7 +24,7 @@ import { updateThread, } from "../services/thread.service.ts"; -type Env = { Variables: { db?: any } }; +type Env = { Variables: { db?: any; userId?: number } }; const app = new Hono(); @@ -32,42 +32,47 @@ const app = new Hono(); app.get("/", async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const includeResolved = c.req.query("includeResolved") === "true"; - const threads = await getAllThreads(db, includeResolved); + const threads = await getAllThreads(db, userId, includeResolved); return c.json(threads); }); app.post("/", zValidator("json", createThreadSchema), async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const data = c.req.valid("json"); - const thread = await createThread(db, data); + const thread = await createThread(db, userId, data); return c.json(thread, 201); }); app.get("/:id", async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const id = parseId(c.req.param("id")); if (!id) return c.json({ error: "Invalid thread ID" }, 400); - const thread = await getThreadWithCandidates(db, id); + const thread = await getThreadWithCandidates(db, userId, id); if (!thread) return c.json({ error: "Thread not found" }, 404); return c.json(thread); }); app.put("/:id", zValidator("json", updateThreadSchema), async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const id = parseId(c.req.param("id")); if (!id) return c.json({ error: "Invalid thread ID" }, 400); const data = c.req.valid("json"); - const thread = await updateThread(db, id, data); + const thread = await updateThread(db, userId, id, data); if (!thread) return c.json({ error: "Thread not found" }, 404); return c.json(thread); }); app.delete("/:id", async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const id = parseId(c.req.param("id")); if (!id) return c.json({ error: "Invalid thread ID" }, 400); - const deleted = await deleteThread(db, id); + const deleted = await deleteThread(db, userId, id); if (!deleted) return c.json({ error: "Thread not found" }, 404); // Clean up candidate image files @@ -89,15 +94,16 @@ app.post( zValidator("json", createCandidateSchema), async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const threadId = parseId(c.req.param("id")); if (!threadId) return c.json({ error: "Invalid thread ID" }, 400); // Verify thread exists - const thread = await getThreadWithCandidates(db, threadId); + const thread = await getThreadWithCandidates(db, userId, threadId); if (!thread) return c.json({ error: "Thread not found" }, 404); const data = c.req.valid("json"); - const candidate = await createCandidate(db, threadId, data); + const candidate = await createCandidate(db, userId, threadId, data); return c.json(candidate, 201); }, ); @@ -107,10 +113,11 @@ app.put( zValidator("json", updateCandidateSchema), async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const candidateId = parseId(c.req.param("candidateId")); if (!candidateId) return c.json({ error: "Invalid candidate ID" }, 400); const data = c.req.valid("json"); - const candidate = await updateCandidate(db, candidateId, data); + const candidate = await updateCandidate(db, userId, candidateId, data); if (!candidate) return c.json({ error: "Candidate not found" }, 404); return c.json(candidate); }, @@ -118,9 +125,10 @@ app.put( app.delete("/:threadId/candidates/:candidateId", async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const candidateId = parseId(c.req.param("candidateId")); if (!candidateId) return c.json({ error: "Invalid candidate ID" }, 400); - const deleted = await deleteCandidate(db, candidateId); + const deleted = await deleteCandidate(db, userId, candidateId); if (!deleted) return c.json({ error: "Candidate not found" }, 404); // Clean up image file if exists @@ -142,10 +150,11 @@ app.patch( zValidator("json", reorderCandidatesSchema), async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const threadId = parseId(c.req.param("id")); if (!threadId) return c.json({ error: "Invalid thread ID" }, 400); const { orderedIds } = c.req.valid("json"); - const result = await reorderCandidates(db, threadId, orderedIds); + const result = await reorderCandidates(db, userId, threadId, orderedIds); if (!result.success) return c.json({ error: result.error }, 400); return c.json({ success: true }); }, @@ -155,11 +164,12 @@ app.patch( app.post("/:id/resolve", zValidator("json", resolveThreadSchema), async (c) => { const db = c.get("db"); + const userId = c.get("userId")!; const threadId = parseId(c.req.param("id")); if (!threadId) return c.json({ error: "Invalid thread ID" }, 400); const { candidateId } = c.req.valid("json"); - const result = await resolveThread(db, threadId, candidateId); + const result = await resolveThread(db, userId, threadId, candidateId); if (!result.success) { return c.json({ error: result.error }, 400); } diff --git a/src/server/routes/totals.ts b/src/server/routes/totals.ts index 6abe352..d37f2ed 100644 --- a/src/server/routes/totals.ts +++ b/src/server/routes/totals.ts @@ -4,14 +4,15 @@ import { getGlobalTotals, } from "../services/totals.service.ts"; -type Env = { Variables: { db?: any } }; +type Env = { Variables: { db?: any; userId?: number } }; const app = new Hono(); app.get("/", async (c) => { const db = c.get("db"); - const categoryTotals = await getCategoryTotals(db); - const globalTotals = await getGlobalTotals(db); + const userId = c.get("userId")!; + const categoryTotals = await getCategoryTotals(db, userId); + const globalTotals = await getGlobalTotals(db, userId); return c.json({ categories: categoryTotals, global: globalTotals }); }); From d4bf4f5c16202aea18c4a72a2574aa3fd533c45d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 5 Apr 2026 10:52:43 +0200 Subject: [PATCH 2/3] feat(16-03): wire userId into MCP server and tool registrations - Update createMcpServer signature to accept (db, userId) - MCP auth middleware resolves userId from API key and Bearer token - Store userId alongside transport in session map - All 4 tool registration functions accept and pass userId - Collection summary resource passes userId to all service calls --- src/server/mcp/index.ts | 55 +++++++++++++++----------- src/server/mcp/resources/collection.ts | 12 +++--- src/server/mcp/tools/categories.ts | 6 +-- src/server/mcp/tools/items.ts | 12 +++--- src/server/mcp/tools/setups.ts | 14 +++---- src/server/mcp/tools/threads.ts | 25 ++++++++---- 6 files changed, 70 insertions(+), 54 deletions(-) diff --git a/src/server/mcp/index.ts b/src/server/mcp/index.ts index a039a98..997244c 100644 --- a/src/server/mcp/index.ts +++ b/src/server/mcp/index.ts @@ -17,32 +17,32 @@ import { registerThreadTools, threadToolDefinitions } from "./tools/threads.ts"; type Db = typeof prodDb; -function createMcpServer(db: Db): McpServer { +function createMcpServer(db: Db, userId: number): McpServer { const server = new McpServer({ name: "GearBox", version: "1.0.0" }); // Register item tools - const itemHandlers = registerItemTools(db); + const itemHandlers = registerItemTools(db, userId); for (const def of itemToolDefinitions) { const handler = itemHandlers[def.name as keyof typeof itemHandlers]; server.tool(def.name, def.description, def.inputSchema, handler); } // Register category tools - const categoryHandlers = registerCategoryTools(db); + const categoryHandlers = registerCategoryTools(db, userId); for (const def of categoryToolDefinitions) { const handler = categoryHandlers[def.name as keyof typeof categoryHandlers]; server.tool(def.name, def.description, def.inputSchema, handler); } // Register thread tools - const threadHandlers = registerThreadTools(db); + const threadHandlers = registerThreadTools(db, userId); for (const def of threadToolDefinitions) { const handler = threadHandlers[def.name as keyof typeof threadHandlers]; server.tool(def.name, def.description, def.inputSchema, handler); } // Register setup tools - const setupHandlers = registerSetupTools(db); + const setupHandlers = registerSetupTools(db, userId); for (const def of setupToolDefinitions) { const handler = setupHandlers[def.name as keyof typeof setupHandlers]; server.tool(def.name, def.description, def.inputSchema, handler); @@ -65,7 +65,7 @@ function createMcpServer(db: Db): McpServer { mimeType: "application/json", }, async () => { - const summary = await getCollectionSummary(db); + const summary = await getCollectionSummary(db, userId); return { contents: [ { @@ -81,12 +81,15 @@ function createMcpServer(db: Db): McpServer { return server; } -// Store active transports by session ID -const transports = new Map(); +// Store active transports by session ID (with userId for session reuse) +const transports = new Map< + string, + { transport: WebStandardStreamableHTTPServerTransport; userId: number } +>(); export const mcpRoutes = new Hono(); -// Auth middleware for all MCP requests +// Auth middleware for all MCP requests — resolves userId mcpRoutes.use("/*", async (c, next) => { const db = c.get("db") ?? prodDb; @@ -94,7 +97,9 @@ mcpRoutes.use("/*", async (c, next) => { const authHeader = c.req.header("Authorization"); if (authHeader?.startsWith("Bearer ")) { const token = authHeader.slice(7); - if (await verifyAccessToken(db, token)) { + const result = await verifyAccessToken(db, token); + if (result) { + c.set("userId", result.userId); return next(); } return c.json({ error: "invalid_token" }, 401); @@ -103,8 +108,9 @@ mcpRoutes.use("/*", async (c, next) => { // Try API key const apiKey = c.req.header("X-API-Key"); if (apiKey) { - const valid = await verifyApiKey(db, apiKey); - if (valid) { + const result = await verifyApiKey(db, apiKey); + if (result) { + c.set("userId", result.userId); return next(); } return c.json({ error: "Invalid API key" }, 401); @@ -121,16 +127,17 @@ mcpRoutes.use("/*", async (c, next) => { mcpRoutes.post("/", async (c) => { const db = c.get("db") ?? prodDb; + const userId = c.get("userId") as number; // Check for existing session const sessionId = c.req.header("mcp-session-id"); if (sessionId) { - const transport = transports.get(sessionId); - if (!transport) { + const session = transports.get(sessionId); + if (!session) { return c.json({ error: "Session not found" }, 404); } - const response = await transport.handleRequest(c.req.raw); + const response = await session.transport.handleRequest(c.req.raw); return response; } @@ -138,19 +145,19 @@ mcpRoutes.post("/", async (c) => { const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (newSessionId) => { - transports.set(newSessionId, transport); + transports.set(newSessionId, { transport, userId }); }, }); // Clean up on close transport.onclose = () => { const sid = [...transports.entries()].find( - ([_, t]) => t === transport, + ([_, s]) => s.transport === transport, )?.[0]; if (sid) transports.delete(sid); }; - const server = createMcpServer(db); + const server = createMcpServer(db, userId); await server.connect(transport); const response = await transport.handleRequest(c.req.raw); @@ -163,12 +170,12 @@ mcpRoutes.get("/", async (c) => { return c.json({ error: "Session ID required" }, 400); } - const transport = transports.get(sessionId); - if (!transport) { + const session = transports.get(sessionId); + if (!session) { return c.json({ error: "Session not found" }, 404); } - const response = await transport.handleRequest(c.req.raw); + const response = await session.transport.handleRequest(c.req.raw); return response; }); @@ -178,12 +185,12 @@ mcpRoutes.delete("/", async (c) => { return c.json({ error: "Session ID required" }, 400); } - const transport = transports.get(sessionId); - if (!transport) { + const session = transports.get(sessionId); + if (!session) { return c.json({ error: "Session not found" }, 404); } - await transport.close(); + await session.transport.close(); transports.delete(sessionId); return c.text("", 200); }); diff --git a/src/server/mcp/resources/collection.ts b/src/server/mcp/resources/collection.ts index e5860b4..f441c5f 100644 --- a/src/server/mcp/resources/collection.ts +++ b/src/server/mcp/resources/collection.ts @@ -7,12 +7,12 @@ import { getGlobalTotals } from "../../services/totals.service.ts"; type Db = typeof prodDb; -export async function getCollectionSummary(db: Db) { - const totals = await getGlobalTotals(db); - const categories = await getAllCategories(db); - const items = await getAllItems(db); - const setups = await getAllSetups(db); - const activeThreads = await getAllThreads(db, false); +export async function getCollectionSummary(db: Db, userId: number) { + const totals = await getGlobalTotals(db, userId); + const categories = await getAllCategories(db, userId); + const items = await getAllItems(db, userId); + const setups = await getAllSetups(db, userId); + const activeThreads = await getAllThreads(db, userId, false); // Build items-by-category map const itemsByCategory: Record = {}; diff --git a/src/server/mcp/tools/categories.ts b/src/server/mcp/tools/categories.ts index b2e7394..2a28f6a 100644 --- a/src/server/mcp/tools/categories.ts +++ b/src/server/mcp/tools/categories.ts @@ -37,11 +37,11 @@ export const categoryToolDefinitions = [ }, ]; -export function registerCategoryTools(db: Db) { +export function registerCategoryTools(db: Db, userId: number) { return { list_categories: async (): Promise => { try { - const cats = await getAllCategories(db); + const cats = await getAllCategories(db, userId); return textResult(cats); } catch (err) { return errorResult((err as Error).message); @@ -53,7 +53,7 @@ export function registerCategoryTools(db: Db) { icon?: string; }): Promise => { try { - const cat = await createCategory(db, args); + const cat = await createCategory(db, userId, args); return textResult(cat); } catch (err) { return errorResult((err as Error).message); diff --git a/src/server/mcp/tools/items.ts b/src/server/mcp/tools/items.ts index c65b5e8..95e6aa3 100644 --- a/src/server/mcp/tools/items.ts +++ b/src/server/mcp/tools/items.ts @@ -91,11 +91,11 @@ export const itemToolDefinitions = [ }, ]; -export function registerItemTools(db: Db) { +export function registerItemTools(db: Db, userId: number) { return { list_items: async (args: { categoryId?: number }): Promise => { try { - const items = await getAllItems(db); + const items = await getAllItems(db, userId); if (args.categoryId) { return textResult( items.filter((i) => i.categoryId === args.categoryId), @@ -109,7 +109,7 @@ export function registerItemTools(db: Db) { get_item: async (args: { id: number }): Promise => { try { - const item = await getItemById(db, args.id); + const item = await getItemById(db, userId, args.id); if (!item) return errorResult(`Item ${args.id} not found`); return textResult(item); } catch (err) { @@ -128,7 +128,7 @@ export function registerItemTools(db: Db) { imageSourceUrl?: string; }): Promise => { try { - const item = await createItem(db, args); + const item = await createItem(db, userId, args); return textResult(item); } catch (err) { return errorResult((err as Error).message); @@ -148,7 +148,7 @@ export function registerItemTools(db: Db) { }): Promise => { try { const { id, ...data } = args; - const item = await updateItem(db, id, data); + const item = await updateItem(db, userId, id, data); if (!item) return errorResult(`Item ${id} not found`); return textResult(item); } catch (err) { @@ -158,7 +158,7 @@ export function registerItemTools(db: Db) { delete_item: async (args: { id: number }): Promise => { try { - const item = await deleteItem(db, args.id); + const item = await deleteItem(db, userId, args.id); if (!item) return errorResult(`Item ${args.id} not found`); return textResult({ deleted: true, item }); } catch (err) { diff --git a/src/server/mcp/tools/setups.ts b/src/server/mcp/tools/setups.ts index 1b6581b..b7e42b2 100644 --- a/src/server/mcp/tools/setups.ts +++ b/src/server/mcp/tools/setups.ts @@ -60,11 +60,11 @@ export const setupToolDefinitions = [ }, ]; -export function registerSetupTools(db: Db) { +export function registerSetupTools(db: Db, userId: number) { return { list_setups: async (): Promise => { try { - const setupList = await getAllSetups(db); + const setupList = await getAllSetups(db, userId); return textResult(setupList); } catch (err) { return errorResult((err as Error).message); @@ -73,7 +73,7 @@ export function registerSetupTools(db: Db) { get_setup: async (args: { id: number }): Promise => { try { - const setup = await getSetupWithItems(db, args.id); + const setup = await getSetupWithItems(db, userId, args.id); if (!setup) return errorResult(`Setup ${args.id} not found`); return textResult(setup); } catch (err) { @@ -83,7 +83,7 @@ export function registerSetupTools(db: Db) { create_setup: async (args: { name: string }): Promise => { try { - const setup = await createSetup(db, args); + const setup = await createSetup(db, userId, args); return textResult(setup); } catch (err) { return errorResult((err as Error).message); @@ -98,14 +98,14 @@ export function registerSetupTools(db: Db) { try { let setup = null; if (args.name) { - setup = await updateSetup(db, args.id, { name: args.name }); + setup = await updateSetup(db, userId, args.id, { name: args.name }); if (!setup) return errorResult(`Setup ${args.id} not found`); } if (args.itemIds) { - await syncSetupItems(db, args.id, args.itemIds); + await syncSetupItems(db, userId, args.id, args.itemIds); } // Return updated setup with items - const result = await getSetupWithItems(db, args.id); + const result = await getSetupWithItems(db, userId, args.id); if (!result) return errorResult(`Setup ${args.id} not found`); return textResult(result); } catch (err) { diff --git a/src/server/mcp/tools/threads.ts b/src/server/mcp/tools/threads.ts index ec02984..7392f62 100644 --- a/src/server/mcp/tools/threads.ts +++ b/src/server/mcp/tools/threads.ts @@ -113,13 +113,17 @@ export const threadToolDefinitions = [ }, ]; -export function registerThreadTools(db: Db) { +export function registerThreadTools(db: Db, userId: number) { return { list_threads: async (args: { includeResolved?: boolean; }): Promise => { try { - const threadList = await getAllThreads(db, args.includeResolved ?? false); + const threadList = await getAllThreads( + db, + userId, + args.includeResolved ?? false, + ); return textResult(threadList); } catch (err) { return errorResult((err as Error).message); @@ -128,7 +132,7 @@ export function registerThreadTools(db: Db) { get_thread: async (args: { id: number }): Promise => { try { - const thread = await getThreadWithCandidates(db, args.id); + const thread = await getThreadWithCandidates(db, userId, args.id); if (!thread) return errorResult(`Thread ${args.id} not found`); return textResult(thread); } catch (err) { @@ -141,7 +145,7 @@ export function registerThreadTools(db: Db) { categoryId: number; }): Promise => { try { - const thread = await createThread(db, args); + const thread = await createThread(db, userId, args); return textResult(thread); } catch (err) { return errorResult((err as Error).message); @@ -153,7 +157,12 @@ export function registerThreadTools(db: Db) { candidateId: number; }): Promise => { try { - const result = await resolveThread(db, args.threadId, args.candidateId); + const result = await resolveThread( + db, + userId, + args.threadId, + args.candidateId, + ); if (!result.success) { return errorResult(result.error ?? "Failed to resolve thread"); } @@ -177,7 +186,7 @@ export function registerThreadTools(db: Db) { }): Promise => { try { const { threadId, ...data } = args; - const candidate = await createCandidate(db, threadId, data); + const candidate = await createCandidate(db, userId, threadId, data); return textResult(candidate); } catch (err) { return errorResult((err as Error).message); @@ -200,7 +209,7 @@ export function registerThreadTools(db: Db) { }): Promise => { try { const { id, ...data } = args; - const candidate = await updateCandidate(db, id, data); + const candidate = await updateCandidate(db, userId, id, data); if (!candidate) return errorResult(`Candidate ${id} not found`); return textResult(candidate); } catch (err) { @@ -210,7 +219,7 @@ export function registerThreadTools(db: Db) { remove_candidate: async (args: { id: number }): Promise => { try { - const candidate = await deleteCandidate(db, args.id); + const candidate = await deleteCandidate(db, userId, args.id); if (!candidate) return errorResult(`Candidate ${args.id} not found`); return textResult({ deleted: true, candidate }); } catch (err) { From 14f1b22c35fd215b7b932e248c2f19bbd9985474 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 5 Apr 2026 10:54:50 +0200 Subject: [PATCH 3/3] docs(16-03): complete route and MCP userId wiring plan - SUMMARY.md documenting 2 tasks, 13 files modified - STATE.md updated with plan progress and decisions - ROADMAP.md marks 16-03 complete - REQUIREMENTS.md marks MULTI-05 complete --- .planning/REQUIREMENTS.md | 4 +- .planning/ROADMAP.md | 2 +- .planning/STATE.md | 13 +- .../16-multi-user-data-model/16-03-SUMMARY.md | 124 ++++++++++++++++++ 4 files changed, 135 insertions(+), 8 deletions(-) create mode 100644 .planning/phases/16-multi-user-data-model/16-03-SUMMARY.md diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 06481ee..2b36cb6 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -29,7 +29,7 @@ Requirements for this milestone. Each maps to roadmap phases. - [x] **MULTI-02**: User can only see and modify their own data (cross-user isolation) - [x] **MULTI-03**: Categories use composite unique constraint (userId + name) - [x] **MULTI-04**: Existing data is assigned to the original user during migration -- [ ] **MULTI-05**: MCP tools operate within the authenticated user's scope +- [x] **MULTI-05**: MCP tools operate within the authenticated user's scope - [x] **MULTI-06**: Settings are per-user rather than global ### Image Storage @@ -130,7 +130,7 @@ Which phases cover which requirements. Updated during roadmap creation. | MULTI-02 | Phase 16 | Complete | | MULTI-03 | Phase 16 | Complete | | MULTI-04 | Phase 16 | Complete | -| MULTI-05 | Phase 16 | Pending | +| MULTI-05 | Phase 16 | Complete | | MULTI-06 | Phase 16 | Complete | | IMG-01 | Phase 17 | Pending | | IMG-02 | Phase 17 | Pending | diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index e79cbeb..9036016 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -149,7 +149,7 @@ Plans: Plans: - [x] 16-01-PLAN.md — Schema foundation: users table, userId columns, auth middleware, test helper - [x] 16-02-PLAN.md — Service layer userId scoping -- [ ] 16-03-PLAN.md — Route handlers userId extraction +- [x] 16-03-PLAN.md — Route handlers userId extraction - [ ] 16-04-PLAN.md — Test suite updates ### Phase 17: Object Storage diff --git a/.planning/STATE.md b/.planning/STATE.md index 7cf3eaa..6b52cb9 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -26,7 +26,7 @@ See: .planning/PROJECT.md (updated 2026-04-03) ## Current Position Phase: 16 of 18 (Multi-User Data Model) -Plan: 2 of 4 in current phase +Plan: 3 of 4 in current phase Status: Ready to execute Last activity: 2026-04-05 @@ -36,9 +36,9 @@ Progress: [#---------] 5% (v2.0 milestone) **Velocity:** -- Total plans completed: 1 (v2.0 milestone) -- Average duration: 8min -- Total execution time: 8min +- Total plans completed: 2 (v2.0 milestone) +- Average duration: 7min +- Total execution time: 14min *Updated after each plan completion* @@ -60,6 +60,9 @@ Key decisions made during v2.0 execution: - [Phase 16]: Category deletion uses dynamic getOrCreateUncategorized per user instead of hardcoded ID - [Phase 16]: Candidate operations verify parent thread ownership for cross-user isolation - [Phase 16]: syncSetupItems validates both setup and item ownership via inArray +- [Phase 16-03]: Non-null assertion on c.get("userId") since requireAuth guarantees it +- [Phase 16-03]: MCP session map stores userId alongside transport for session reuse +- [Phase 16-03]: Images route unchanged -- no user-scoped DB operations ### Pending Todos @@ -73,5 +76,5 @@ None active. ## Session Continuity Last session: 2026-04-05T08:45:22.408Z -Stopped at: Completed 16-02-PLAN.md +Stopped at: Completed 16-03-PLAN.md Resume file: None diff --git a/.planning/phases/16-multi-user-data-model/16-03-SUMMARY.md b/.planning/phases/16-multi-user-data-model/16-03-SUMMARY.md new file mode 100644 index 0000000..d07dba3 --- /dev/null +++ b/.planning/phases/16-multi-user-data-model/16-03-SUMMARY.md @@ -0,0 +1,124 @@ +--- +phase: 16-multi-user-data-model +plan: 03 +subsystem: api +tags: [hono, mcp, userId, multi-user, routes, middleware] + +# Dependency graph +requires: + - phase: 16-01 + provides: "Schema with userId columns, auth middleware setting c.set('userId')" + - phase: 16-02 + provides: "Service functions accepting userId as second parameter" +provides: + - "All route handlers extract userId from context and pass to services" + - "Settings routes use composite PK [userId, key] for per-user settings" + - "MCP server creation receives userId, all tool registrations pass userId" + - "MCP auth middleware resolves userId from API key and Bearer token" +affects: [16-04, tests, e2e] + +# Tech tracking +tech-stack: + added: [] + patterns: ["userId extraction pattern: const userId = c.get('userId')!", "MCP session stores userId alongside transport"] + +key-files: + created: [] + modified: + - src/server/routes/items.ts + - src/server/routes/categories.ts + - src/server/routes/threads.ts + - src/server/routes/setups.ts + - src/server/routes/settings.ts + - src/server/routes/totals.ts + - src/server/routes/auth.ts + - src/server/mcp/index.ts + - src/server/mcp/tools/items.ts + - src/server/mcp/tools/categories.ts + - src/server/mcp/tools/threads.ts + - src/server/mcp/tools/setups.ts + - src/server/mcp/resources/collection.ts + +key-decisions: + - "Used non-null assertion (!) on c.get('userId') since requireAuth middleware guarantees it" + - "Stored userId alongside transport in MCP session map for session reuse" + - "Images route left unchanged -- image uploads have no user-scoped DB operations" + +patterns-established: + - "Route handler pattern: const userId = c.get('userId')! after const db = c.get('db')" + - "MCP tool registration pattern: registerXTools(db, userId) with userId closure" + - "Settings composite key: [settings.userId, settings.key] for onConflictDoUpdate target" + +requirements-completed: [MULTI-02, MULTI-05, MULTI-06] + +# Metrics +duration: 6min +completed: 2026-04-05 +--- + +# Phase 16 Plan 03: Route and MCP userId Wiring Summary + +**Complete userId propagation chain from auth middleware through routes and MCP tools to service layer** + +## Performance + +- **Duration:** 6 min +- **Started:** 2026-04-05T08:46:34Z +- **Completed:** 2026-04-05T08:52:52Z +- **Tasks:** 2 +- **Files modified:** 13 + +## Accomplishments +- All 36 route handler calls now extract userId from Hono context and pass to service functions +- Settings routes use composite primary key [userId, key] for per-user settings isolation +- MCP server creation receives userId, all 4 tool registration functions and collection summary pass userId +- MCP auth middleware resolves userId from both API key and Bearer token authentication + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Update all route handlers to extract and pass userId** - `e780022` (feat) +2. **Task 2: Update MCP server and tool registrations with userId** - `d4bf4f5` (feat) + +## Files Created/Modified +- `src/server/routes/items.ts` - Added userId extraction to all 8 handlers (CRUD + export/import/duplicate) +- `src/server/routes/categories.ts` - Added userId extraction to all 4 handlers +- `src/server/routes/threads.ts` - Added userId extraction to all 10 handlers (threads + candidates + reorder + resolve) +- `src/server/routes/setups.ts` - Added userId extraction to all 8 handlers (CRUD + items sync/classification/remove) +- `src/server/routes/settings.ts` - Added userId with composite key [userId, key] for reads and upserts +- `src/server/routes/totals.ts` - Added userId extraction to totals handler +- `src/server/routes/auth.ts` - Added userId extraction to API key management handlers +- `src/server/mcp/index.ts` - Updated createMcpServer(db, userId), MCP auth resolves userId, session map stores userId +- `src/server/mcp/tools/items.ts` - registerItemTools(db, userId) passes userId to all service calls +- `src/server/mcp/tools/categories.ts` - registerCategoryTools(db, userId) passes userId to all service calls +- `src/server/mcp/tools/threads.ts` - registerThreadTools(db, userId) passes userId to all service calls +- `src/server/mcp/tools/setups.ts` - registerSetupTools(db, userId) passes userId to all service calls +- `src/server/mcp/resources/collection.ts` - getCollectionSummary(db, userId) passes userId to all queries + +## Decisions Made +- Used non-null assertion (`!`) on `c.get("userId")` since `requireAuth` middleware guarantees userId is set for all data routes +- Stored userId alongside transport in MCP session map to support session reuse without re-creating MCP server +- Left images route unchanged since image upload/fetch operations have no user-scoped database queries + +## Deviations from Plan + +None - plan executed exactly as written. + +## Issues Encountered +None + +## User Setup Required +None - no external service configuration required. + +## Next Phase Readiness +- Complete userId propagation chain is in place: middleware -> routes/MCP -> services -> database +- Ready for Plan 04 (test updates) to verify the multi-user data isolation + +## Self-Check: PASSED + +All files verified present, all commits verified in history. + +--- +*Phase: 16-multi-user-data-model* +*Completed: 2026-04-05*