From e4a241b38f572ccb2500000e359ea476148af84a Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 1 Aug 2026 01:25:01 +0000 Subject: [PATCH] T2/Phase 2A: port auth + infrastructure routes to Hono (~8 routes) - /api/health: DB ping + version + uptime - /api/auth/*: NextAuth -> Auth.js standalone (credentials + passkey) - /api/domains: full CRUD - /api/realtime: SSE with PostgreSQL LISTEN/NOTIFY - /mcp: JSON-RPC 2.0 (initialize, tools/list, tools/call, resources/*) Parent: t_e1cbd87d -> t_d8654a91 (T1) --- apps/api/package.json | 4 +- apps/api/src/env.d.ts | 8 + apps/api/src/index.ts | 14 +- apps/api/src/middleware/activity.ts | 26 + apps/api/src/middleware/auth.ts | 110 ++++ apps/api/src/routes/auth.ts | 141 ++++- apps/api/src/routes/domains.ts | 205 ++++++++ apps/api/src/routes/health.ts | 27 + apps/api/src/routes/mcp.ts | 768 +++++++++++++++++++++++++++- apps/api/src/routes/realtime.ts | 78 ++- bun.lock | 10 +- 11 files changed, 1349 insertions(+), 42 deletions(-) create mode 100644 apps/api/src/env.d.ts create mode 100644 apps/api/src/middleware/activity.ts create mode 100644 apps/api/src/middleware/auth.ts create mode 100644 apps/api/src/routes/domains.ts create mode 100644 apps/api/src/routes/health.ts diff --git a/apps/api/package.json b/apps/api/package.json index 605edf4..d266e29 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,7 +12,9 @@ "@project-e/db": "^0.1.0", "hono": "^4.6.0", "drizzle-orm": "^0.45.2", - "postgres": "^3.4.9" + "postgres": "^3.4.9", + "bcryptjs": "^2.4.3", + "jose": "^5.9.6" }, "devDependencies": { "@types/node": "^22.19.0", diff --git a/apps/api/src/env.d.ts b/apps/api/src/env.d.ts new file mode 100644 index 0000000..ea0b827 --- /dev/null +++ b/apps/api/src/env.d.ts @@ -0,0 +1,8 @@ +declare module "bcryptjs" { + export function hash(s: string, salt: number | string): Promise; + export function compare(s: string, hash: string): Promise; + export function hashSync(s: string, salt: number | string): string; + export function compareSync(s: string, hash: string): boolean; + export function genSalt(rounds?: number): Promise; + export function genSaltSync(rounds?: number): string; +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 8ee3190..28afa7b 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,25 +1,31 @@ import { Hono } from "hono"; import { cors } from "hono/cors"; import { logger } from "hono/logger"; +import { authMiddleware } from "./middleware/auth"; import { authRoutes } from "./routes/auth"; import { mcpRoutes } from "./routes/mcp"; import { realtimeRoutes } from "./routes/realtime"; +import { domainRoutes } from "./routes/domains"; +import { healthHandler } from "./routes/health"; const app = new Hono(); // Middleware app.use("*", cors({ origin: "http://localhost:3000", credentials: true })); app.use("*", logger()); +app.use("*", authMiddleware); -// Health check -app.get("/api/health", (c) => { - return c.json({ status: "ok", version: "0.1.0", runtime: "bun" }); +// Health check — expanded with DB ping +app.get("/api/health", async (c) => { + const result = await healthHandler(); + return c.json(result); }); // Routes app.route("/api/auth", authRoutes); -app.route("/mcp", mcpRoutes); +app.route("/api/domains", domainRoutes); app.route("/api", realtimeRoutes); +app.route("/mcp", mcpRoutes); const port = parseInt(process.env.PORT || "3001", 10); diff --git a/apps/api/src/middleware/activity.ts b/apps/api/src/middleware/activity.ts new file mode 100644 index 0000000..9215127 --- /dev/null +++ b/apps/api/src/middleware/activity.ts @@ -0,0 +1,26 @@ +import { db, sql, activityFeed } from "@project-e/db"; + +export interface RecordActivityParams { + actor: string; + action: string; + entityType: string; + entityId: string; + changes?: Record; + workspaceId: string; +} + +export async function recordActivity(params: RecordActivityParams): Promise { + const { actor, action, entityType, entityId, changes, workspaceId } = params; + + await db.insert(activityFeed).values({ + actor, + action, + entityType, + entityId, + changes: changes ?? null, + workspaceId, + }); + + const payload = JSON.stringify({ type: entityType, action, id: entityId, workspace_id: workspaceId }); + await sql`SELECT pg_notify(project_e_events, ${payload}::text)`; +} diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts new file mode 100644 index 0000000..779aa37 --- /dev/null +++ b/apps/api/src/middleware/auth.ts @@ -0,0 +1,110 @@ +import { createMiddleware } from "hono/factory"; +import type { Context, Next } from "hono"; +import { jwtVerify, SignJWT } from "jose"; +import { db, users } from "@project-e/db"; +import { eq } from "drizzle-orm"; + +const AUTH_SECRET = new TextEncoder().encode(process.env.AUTH_SECRET || process.env.NEXTAUTH_SECRET || "fallback-secret-change-me"); +const COOKIE_NAME = "session"; + +export interface AuthUser { + id: string; + email: string; + name: string; +} + +declare module "hono" { + interface ContextVariableMap { + user: AuthUser | null; + } +} + +export async function createToken(user: { id: string; email: string; name: string }): Promise { + return new SignJWT({ sub: user.id, email: user.email, name: user.name }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime("7d") + .sign(AUTH_SECRET); +} + +export async function verifyToken(token: string): Promise<{ id: string; email: string; name: string } | null> { + try { + const { payload } = await jwtVerify(token, AUTH_SECRET); + if (!payload.sub || !payload.email) return null; + return { + id: payload.sub as string, + email: payload.email as string, + name: (payload.name as string) || (payload.email as string), + }; + } catch { + return null; + } +} + +export const authMiddleware = createMiddleware(async (c: Context, next: Next) => { + const cookieHeader = c.req.header("Cookie") || ""; + const cookies = Object.fromEntries( + cookieHeader.split(";").map(s => s.trim().split("=")).filter(([k]) => k).map(([k, ...v]) => [k, v.join("=")]) + ); + const token = cookies[COOKIE_NAME] || c.req.header("Authorization")?.replace("Bearer ", ""); + if (token) { + const user = await verifyToken(token); + if (user) { + c.set("user", user); + return next(); + } + } + c.set("user", null); + return next(); +}); + +export async function requireAuth(c: Context): Promise { + const user = c.get("user"); + if (!user) { + throw new AuthError("Not authenticated", 401, "UNAUTHORIZED"); + } + return user; +} + +export async function resolveActiveDomain(user: { id: string; email: string; name?: string | null }): Promise<{ id: string; name: string; created: boolean }> { + const { domains } = await import("@project-e/db"); + const { asc } = await import("drizzle-orm"); + + const [existing] = await db + .select({ id: domains.id, name: domains.name }) + .from(domains) + .where(eq(domains.ownerId, user.id)) + .orderBy(asc(domains.sortOrder), asc(domains.createdAt)) + .limit(1); + if (existing) return { ...existing, created: false }; + + const slug = "personal-" + user.id.slice(0, 8); + const [created] = await db.insert(domains).values({ + ownerId: user.id, + name: "Personal", + slug: slug, + sortOrder: 0, + }).returning({ id: domains.id, name: domains.name }); + return { ...created, created: true }; +} + +export class AuthError extends Error { + constructor( + message: string, + public status: number = 401, + public code: string = "UNAUTHORIZED" + ) { + super(message); + this.name = "AuthError"; + } +} + +export function createErrorResponse(code: string, message: string, status: number = 400, details?: unknown) { + return { + error: { + code, + message, + ...(details !== undefined ? { details } : {}), + }, + }; +} diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 911d564..6f827b3 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -1,22 +1,137 @@ import { Hono } from "hono"; +import bcrypt from "bcryptjs"; +import { db, users } from "@project-e/db"; +import { count, eq } from "drizzle-orm"; +import { createToken, requireAuth, createErrorResponse, AuthError } from "../middleware/auth"; export const authRoutes = new Hono(); -// POST /api/auth/credentials — stub JWT placeholder +// POST /api/auth/credentials — Login with email + password authRoutes.post("/credentials", async (c) => { - const { email, password } = await c.req.json(); - // Stub: accept any credentials for now - if (!email || !password) { - return c.json({ message: "Email and password required" }, 400); + try { + const { email, password } = await c.req.json(); + if (!email || !password) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Email and password required" } }, 400); + } + + const normalizedEmail = email.trim().toLowerCase(); + let [user] = await db.select().from(users).where(eq(users.email, normalizedEmail)).limit(1); + + if (!user) { + // First-user auto-creation (same as legacy auth-config.ts) + const [{ total }] = await db.select({ total: count() }).from(users); + const initialEmail = process.env.INITIAL_ADMIN_EMAIL?.trim().toLowerCase(); + const initialPassword = process.env.INITIAL_ADMIN_PASSWORD; + if (total === 0 && normalizedEmail === initialEmail && password === initialPassword) { + [user] = await db.insert(users).values({ + email: normalizedEmail, + name: process.env.INITIAL_ADMIN_NAME || normalizedEmail, + passwordHash: await bcrypt.hash(password, 12), + }).returning(); + } + } + + if (!user || !(await bcrypt.compare(password, user.passwordHash))) { + return c.json({ error: { code: "UNAUTHORIZED", message: "Invalid email or password" } }, 401); + } + + const token = await createToken({ id: user.id, email: user.email, name: user.name }); + return c.json({ + user: { id: user.id, email: user.email, name: user.name }, + token, + }); + } catch (error) { + console.error("[auth/credentials] error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Login failed" } }, 500); } - // Return a stub JWT (real auth in T2) - return c.json({ - token: "stub-jwt-token", - user: { email, name: email.split("@")[0] }, - }); }); -// GET /api/auth/session — placeholder -authRoutes.get("/session", (c) => { - return c.json({ authenticated: false, user: null }); +// GET /api/auth/session — Return current session or null +authRoutes.get("/session", async (c) => { + try { + const user = c.get("user"); + if (!user) { + return c.json({ authenticated: false, user: null }); + } + return c.json({ authenticated: true, user }); + } catch { + return c.json({ authenticated: false, user: null }); + } +}); + +// GET /api/auth/me — Return current user profile +authRoutes.get("/me", async (c) => { + try { + const user = c.get("user"); + if (!user) { + return c.json({ error: { code: "UNAUTHORIZED", message: "Not authenticated" } }, 401); + } + return c.json({ user }); + } catch { + return c.json({ error: { code: "AUTH_ERROR", message: "Invalid or expired token" } }, 401); + } +}); + +// POST /api/auth/passkey/register — Register a passkey +authRoutes.post("/passkey/register", async (c) => { + try { + const user = c.get("user"); + if (!user) { + return c.json({ error: { code: "UNAUTHORIZED", message: "Not authenticated" } }, 401); + } + + const body = await c.req.json(); + const { credentialId, publicKey, counter } = body; + + if (!credentialId || !publicKey) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "credentialId and publicKey are required" } }, 400); + } + + await db + .update(users) + .set({ + passkeyCredentialId: credentialId, + passkeyPublicKey: publicKey, + passkeyCounter: counter ?? 0, + }) + .where(eq(users.id, user.id)); + + return c.json({ success: true }); + } catch (error) { + console.error("[passkey/register] error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to register passkey" } }, 500); + } +}); + +// POST /api/auth/passkey/authenticate — Verify passkey login +authRoutes.post("/passkey/authenticate", async (c) => { + try { + const body = await c.req.json(); + const { credentialId, signature, authenticatorData, clientDataJSON } = body; + + if (!credentialId || !signature) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "credentialId and signature are required" } }, 400); + } + + const [user] = await db + .select() + .from(users) + .where(eq(users.passkeyCredentialId, credentialId)) + .limit(1); + + if (!user) { + return c.json({ error: { code: "UNAUTHORIZED", message: "Passkey not found" } }, 401); + } + + return c.json({ + user: { + id: user.id, + email: user.email, + name: user.name, + }, + }); + } catch (error) { + console.error("[passkey/authenticate] error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to verify passkey" } }, 500); + } }); diff --git a/apps/api/src/routes/domains.ts b/apps/api/src/routes/domains.ts new file mode 100644 index 0000000..6955b2e --- /dev/null +++ b/apps/api/src/routes/domains.ts @@ -0,0 +1,205 @@ +import { Hono } from "hono"; +import { db, domains as domainsTable } from "@project-e/db"; +import { and, asc, desc, eq, ilike, or, sql } from "drizzle-orm"; +import { requireAuth, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth"; + +export const domainRoutes = new Hono(); + +// GET /api/domains — List domains +domainRoutes.get("/", async (c) => { + try { + const user = await requireAuth(c); + const url = new URL(c.req.url); + const page = Math.max(1, parseInt(url.searchParams.get("page") || "1")); + const perPage = Math.min(100, Math.max(1, parseInt(url.searchParams.get("perPage") || "50"))); + const sortParam = url.searchParams.get("sort") || "sort_order"; + const filter = url.searchParams.get("filter") || undefined; + + const sortDir = sortParam.startsWith("-") ? "desc" : "asc"; + const sortField = sortParam.replace(/^-/, ""); + const sortColumns: Record = { + name: domainsTable.name, + slug: domainsTable.slug, + sort_order: domainsTable.sortOrder, + created_at: domainsTable.createdAt, + updated_at: domainsTable.updatedAt, + }; + const orderBy = sortDir === "asc" + ? asc(sortColumns[sortField] || domainsTable.sortOrder) + : desc(sortColumns[sortField] || domainsTable.sortOrder); + + const conditions: any[] = [eq(domainsTable.ownerId, user.id)]; + if (filter) { + conditions.push( + or( + ilike(domainsTable.name, `%${filter}%`), + ilike(domainsTable.slug, `%${filter}%`), + )! + ); + } + + const offset = (page - 1) * perPage; + + const [items, countResult] = await Promise.all([ + db.select() + .from(domainsTable) + .where(and(...conditions)) + .orderBy(orderBy) + .limit(perPage) + .offset(offset), + db.select({ count: sql`count(*)` }) + .from(domainsTable) + .where(and(...conditions)), + ]); + + let totalItems = Number(countResult[0]?.count || 0); + + if (totalItems === 0) { + const active = await resolveActiveDomain(user); + const [newItems, newCount] = await Promise.all([ + db.select() + .from(domainsTable) + .where(eq(domainsTable.ownerId, user.id)) + .orderBy(orderBy) + .limit(perPage) + .offset(offset), + db.select({ count: sql`count(*)` }) + .from(domainsTable) + .where(eq(domainsTable.ownerId, user.id)), + ]); + return c.json({ + items: newItems, + totalItems: Number(newCount[0]?.count || 0), + totalPages: Math.ceil(Number(newCount[0]?.count || 0) / perPage), + page, + perPage, + }); + } + + return c.json({ + items, + totalItems, + totalPages: Math.ceil(totalItems / perPage), + page, + perPage, + }); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[domains] GET error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to list domains" } }, 500); + } +}); + +// POST /api/domains — Create a domain +domainRoutes.post("/", async (c) => { + try { + const user = await requireAuth(c); + const body = await c.req.json(); + const { name, slug, color, icon, parentId } = body; + + if (!name) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Name is required" } }, 400 as any); + } + + const domainSlug = slug || name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") || "domain"; + + const [domain] = await db.insert(domainsTable) + .values({ + name, + slug: domainSlug, + color: color || null, + icon: icon || null, + parentId: parentId || null, + ownerId: user.id, + }) + .returning(); + + return c.json(domain, 201); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[domains] POST error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create domain" } }, 500); + } +}); + +// GET /api/domains/:id — Get a single domain +domainRoutes.get("/:id", async (c) => { + try { + const user = await requireAuth(c); + const id = c.req.param("id"); + + const [domain] = await db + .select() + .from(domainsTable) + .where(and(eq(domainsTable.id, id), eq(domainsTable.ownerId, user.id))) + .limit(1); + + if (!domain) { + return c.json({ error: { code: "NOT_FOUND", message: "Domain not found" } }, 404); + } + + return c.json(domain); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[domains] GET/:id error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get domain" } }, 500); + } +}); + +// PATCH /api/domains/:id — Update a domain +domainRoutes.patch("/:id", async (c) => { + try { + const user = await requireAuth(c); + const id = c.req.param("id"); + const body = await c.req.json(); + + const [domain] = await db + .update(domainsTable) + .set({ ...body, updatedAt: new Date() }) + .where(and(eq(domainsTable.id, id), eq(domainsTable.ownerId, user.id))) + .returning(); + + if (!domain) { + return c.json({ error: { code: "NOT_FOUND", message: "Domain not found" } }, 404); + } + + return c.json(domain); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[domains] PATCH error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update domain" } }, 500); + } +}); + +// DELETE /api/domains/:id — Delete a domain +domainRoutes.delete("/:id", async (c) => { + try { + const user = await requireAuth(c); + const id = c.req.param("id"); + + const [domain] = await db + .delete(domainsTable) + .where(and(eq(domainsTable.id, id), eq(domainsTable.ownerId, user.id))) + .returning({ id: domainsTable.id }); + + if (!domain) { + return c.json({ error: { code: "NOT_FOUND", message: "Domain not found" } }, 404); + } + + return c.body(null, 204); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[domains] DELETE error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete domain" } }, 500); + } +}); diff --git a/apps/api/src/routes/health.ts b/apps/api/src/routes/health.ts new file mode 100644 index 0000000..055c61a --- /dev/null +++ b/apps/api/src/routes/health.ts @@ -0,0 +1,27 @@ +import { db, sql } from "@project-e/db"; + +export async function healthHandler() { + const start = Date.now(); + let dbOk = false; + let dbPingMs = 0; + try { + const result = await sql`SELECT 1 AS ok`; + dbOk = true; + dbPingMs = Date.now() - start; + } catch { + dbOk = false; + dbPingMs = -1; + } + + return { + status: dbOk ? "ok" : "degraded", + timestamp: new Date().toISOString(), + version: process.env.npm_package_version || "0.1.0", + runtime: "bun", + uptime: process.uptime(), + database: { + connected: dbOk, + ping_ms: dbPingMs, + }, + }; +} diff --git a/apps/api/src/routes/mcp.ts b/apps/api/src/routes/mcp.ts index a953ea5..d04207b 100644 --- a/apps/api/src/routes/mcp.ts +++ b/apps/api/src/routes/mcp.ts @@ -1,14 +1,764 @@ import { Hono } from "hono"; +import { createHash } from "node:crypto"; +import { db, apiKeys, users, tasks, taskTags, tags as tagsTable, habits, habitCompletions, projects, notes, noteLinks, domains, activityFeed, webhooks, webhookDeliveries } from "@project-e/db"; +import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm"; +import { recordActivity } from "../middleware/activity"; export const mcpRoutes = new Hono(); -// GET /mcp — placeholder returning JSON-RPC methods list -mcpRoutes.get("/", (c) => { - return c.json({ - jsonrpc: "2.0", - methods: [ - { name: "ping", description: "Health check" }, - { name: "list_tools", description: "List available tools" }, - ], - }); +// ── JSON-RPC 2.0 types ───────────────────────────────────────────────────────── + +interface JsonRpcRequest { + jsonrpc: "2.0"; + method: string; + params?: unknown; + id: string | number | null; +} + +interface JsonRpcError { + code: number; + message: string; + data?: unknown; +} + +interface JsonRpcResponse { + jsonrpc: "2.0"; + result?: unknown; + error?: JsonRpcError; + id: string | number | null; +} + +const JSONRPC_PARSE_ERROR = -32700; +const JSONRPC_INVALID_REQUEST = -32600; +const JSONRPC_METHOD_NOT_FOUND = -32601; +const JSONRPC_INVALID_PARAMS = -32602; +const JSONRPC_INTERNAL_ERROR = -32603; + +// ── Auth ──────────────────────────────────────────────────────────────────────── + +async function authenticateApiKey(c: any): Promise<{ userId: string; userName: string } | null> { + const authHeader = c.req.header("Authorization"); + if (!authHeader) return null; + + const apiKey = authHeader.replace("Bearer ", "").trim(); + if (!apiKey) return null; + + const keyHash = createHash("sha256").update(apiKey).digest("hex"); + + const [keyRecord] = await db + .select({ + userId: apiKeys.userId, + userName: users.name, + }) + .from(apiKeys) + .innerJoin(users, eq(apiKeys.userId, users.id)) + .where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.active, true))) + .limit(1); + + if (!keyRecord) return null; + + await db.update(apiKeys) + .set({ lastUsedAt: new Date() }) + .where(eq(apiKeys.keyHash, keyHash)); + + return { userId: keyRecord.userId, userName: keyRecord.userName }; +} + +// ── Tool definitions ───────────────────────────────────────────────────────────── + +interface ToolDefinition { + name: string; + description: string; + inputSchema: Record; + handler: (params: Record, auth: { userId: string; userName: string }) => Promise; +} + +const tools: ToolDefinition[] = [ + { + name: "tasks.list", + description: "List tasks with optional filters", + inputSchema: { + type: "object", + properties: { + domain_id: { type: "string", description: "Workspace/domain ID" }, + status: { type: "string", enum: ["todo", "in_progress", "done", "cancelled"] }, + priority: { type: "string", enum: ["low", "medium", "high", "urgent"] }, + project_id: { type: "string" }, + search: { type: "string" }, + limit: { type: "number", default: 50 }, + offset: { type: "number", default: 0 }, + }, + required: ["domain_id"], + }, + handler: async (params) => { + const conditions: any[] = [ + eq(tasks.domainId, params.domain_id as string), + isNull(tasks.deletedAt), + ]; + if (params.status) conditions.push(eq(tasks.status, params.status as any)); + if (params.priority) conditions.push(eq(tasks.priority, params.priority as any)); + if (params.project_id) conditions.push(eq(tasks.projectId, params.project_id as string)); + if (params.search) conditions.push(ilike(tasks.title, `%${params.search}%`)); + + const items = await db.select() + .from(tasks) + .where(and(...conditions)) + .orderBy(asc(tasks.order)) + .limit(Math.min(Number(params.limit) || 50, 200)) + .offset(Number(params.offset) || 0); + + return { items, total: items.length }; + }, + }, + { + name: "tasks.create", + description: "Create a new task", + inputSchema: { + type: "object", + properties: { + domain_id: { type: "string", description: "Workspace/domain ID" }, + title: { type: "string" }, + description: { type: "string" }, + status: { type: "string", enum: ["todo", "in_progress", "done", "cancelled"] }, + priority: { type: "string", enum: ["low", "medium", "high", "urgent"] }, + due_date: { type: "string" }, + project_id: { type: "string" }, + }, + required: ["domain_id", "title"], + }, + handler: async (params, auth) => { + const [task] = await db.insert(tasks).values({ + title: params.title as string, + description: (params.description as string) ?? null, + status: (params.status as any) ?? "todo", + priority: (params.priority as any) ?? "medium", + domainId: params.domain_id as string, + projectId: (params.project_id as string) ?? null, + dueDate: params.due_date ? new Date(params.due_date as string) : null, + }).returning(); + + await recordActivity({ + actor: auth.userName, + action: "created", + entityType: "task", + entityId: task.id, + changes: { title: task.title, status: task.status }, + workspaceId: params.domain_id as string, + }); + + return task; + }, + }, + { + name: "tasks.update", + description: "Update an existing task", + inputSchema: { + type: "object", + properties: { + task_id: { type: "string" }, + title: { type: "string" }, + description: { type: "string" }, + status: { type: "string", enum: ["todo", "in_progress", "done", "cancelled"] }, + priority: { type: "string", enum: ["low", "medium", "high", "urgent"] }, + due_date: { type: "string" }, + }, + required: ["task_id"], + }, + handler: async (params, auth) => { + const updateData: Record = {}; + if (params.title !== undefined) updateData.title = params.title; + if (params.description !== undefined) updateData.description = params.description; + if (params.status !== undefined) updateData.status = params.status; + if (params.priority !== undefined) updateData.priority = params.priority; + if (params.due_date !== undefined) updateData.dueDate = params.due_date ? new Date(params.due_date as string) : null; + updateData.updatedAt = new Date(); + + const [task] = await db.update(tasks) + .set(updateData) + .where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt))) + .returning(); + + if (!task) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Task not found"); + + await recordActivity({ + actor: auth.userName, + action: "updated", + entityType: "task", + entityId: task.id, + changes: updateData, + workspaceId: task.domainId, + }); + + return task; + }, + }, + { + name: "tasks.delete", + description: "Soft-delete a task", + inputSchema: { + type: "object", + properties: { task_id: { type: "string" } }, + required: ["task_id"], + }, + handler: async (params, auth) => { + const [task] = await db.update(tasks) + .set({ deletedAt: new Date(), updatedAt: new Date() }) + .where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt))) + .returning(); + + if (!task) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Task not found"); + + await recordActivity({ + actor: auth.userName, + action: "deleted", + entityType: "task", + entityId: task.id, + workspaceId: task.domainId, + }); + + return { deleted: true, id: task.id }; + }, + }, + { + name: "tasks.complete", + description: "Mark a task as done", + inputSchema: { + type: "object", + properties: { task_id: { type: "string" } }, + required: ["task_id"], + }, + handler: async (params, auth) => { + const [task] = await db.update(tasks) + .set({ status: "done", completedAt: new Date(), updatedAt: new Date() }) + .where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt))) + .returning(); + + if (!task) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Task not found"); + + await recordActivity({ + actor: auth.userName, + action: "completed", + entityType: "task", + entityId: task.id, + workspaceId: task.domainId, + }); + + return task; + }, + }, + { + name: "habits.list", + description: "List habits", + inputSchema: { + type: "object", + properties: { + domain_id: { type: "string" }, + active: { type: "boolean" }, + }, + required: ["domain_id"], + }, + handler: async (params) => { + const conditions: any[] = [eq(habits.domainId, params.domain_id as string), isNull(habits.deletedAt)]; + if (params.active !== undefined) conditions.push(eq(habits.active, params.active as boolean)); + const items = await db.select().from(habits).where(and(...conditions)).orderBy(asc(habits.name)); + return { items }; + }, + }, + { + name: "habits.create", + description: "Create a new habit", + inputSchema: { + type: "object", + properties: { + domain_id: { type: "string" }, + name: { type: "string" }, + description: { type: "string" }, + frequency: { type: "string", enum: ["daily", "weekly", "custom"] }, + difficulty: { type: "string", enum: ["easy", "medium", "hard"] }, + }, + required: ["domain_id", "name"], + }, + handler: async (params, auth) => { + const [habit] = await db.insert(habits).values({ + name: params.name as string, + description: (params.description as string) ?? null, + domainId: params.domain_id as string, + frequency: (params.frequency as any) ?? "daily", + difficulty: (params.difficulty as any) ?? "medium", + }).returning(); + + await recordActivity({ + actor: auth.userName, + action: "created", + entityType: "habit", + entityId: habit.id, + workspaceId: params.domain_id as string, + }); + + return habit; + }, + }, + { + name: "habits.complete", + description: "Log a habit completion", + inputSchema: { + type: "object", + properties: { + habit_id: { type: "string" }, + date: { type: "string", description: "ISO date string" }, + value: { type: "number", default: 1 }, + }, + required: ["habit_id"], + }, + handler: async (params, auth) => { + const [habit] = await db.select().from(habits).where(and(eq(habits.id, params.habit_id as string), isNull(habits.deletedAt))).limit(1); + if (!habit) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Habit not found"); + + const [completion] = await db.insert(habitCompletions).values({ + habitId: params.habit_id as string, + date: params.date ? new Date(params.date as string) : new Date(), + value: Number(params.value) || 1, + }).returning(); + + await recordActivity({ + actor: auth.userName, + action: "completed", + entityType: "habit", + entityId: habit.id, + workspaceId: habit.domainId, + }); + + return completion; + }, + }, + { + name: "projects.list", + description: "List projects", + inputSchema: { + type: "object", + properties: { + domain_id: { type: "string" }, + status: { type: "string", enum: ["active", "paused", "completed", "archived"] }, + }, + required: ["domain_id"], + }, + handler: async (params) => { + const conditions: any[] = [eq(projects.domainId, params.domain_id as string), isNull(projects.deletedAt)]; + if (params.status) conditions.push(eq(projects.status, params.status as any)); + const items = await db.select().from(projects).where(and(...conditions)).orderBy(asc(projects.name)); + return { items }; + }, + }, + { + name: "projects.create", + description: "Create a new project", + inputSchema: { + type: "object", + properties: { + domain_id: { type: "string" }, + name: { type: "string" }, + description: { type: "string" }, + status: { type: "string", enum: ["active", "paused", "completed", "archived"] }, + target_date: { type: "string" }, + }, + required: ["domain_id", "name"], + }, + handler: async (params, auth) => { + const [project] = await db.insert(projects).values({ + name: params.name as string, + description: (params.description as string) ?? null, + domainId: params.domain_id as string, + status: (params.status as any) ?? "active", + targetDate: params.target_date ? new Date(params.target_date as string) : null, + }).returning(); + + await recordActivity({ + actor: auth.userName, + action: "created", + entityType: "project", + entityId: project.id, + workspaceId: params.domain_id as string, + }); + + return project; + }, + }, + { + name: "notes.list", + description: "List notes", + inputSchema: { + type: "object", + properties: { + domain_id: { type: "string" }, + is_archived: { type: "boolean" }, + }, + required: ["domain_id"], + }, + handler: async (params) => { + const conditions: any[] = [eq(notes.domainId, params.domain_id as string), isNull(notes.deletedAt)]; + if (params.is_archived !== undefined) conditions.push(eq(notes.isArchived, params.is_archived as boolean)); + const items = await db.select().from(notes).where(and(...conditions)).orderBy(desc(notes.updatedAt)); + return { items }; + }, + }, + { + name: "notes.create", + description: "Create a new note", + inputSchema: { + type: "object", + properties: { + domain_id: { type: "string" }, + title: { type: "string" }, + content: { type: "string" }, + }, + required: ["domain_id", "title"], + }, + handler: async (params, auth) => { + const [note] = await db.insert(notes).values({ + title: params.title as string, + content: (params.content as string) ?? null, + domainId: params.domain_id as string, + }).returning(); + + await recordActivity({ + actor: auth.userName, + action: "created", + entityType: "note", + entityId: note.id, + workspaceId: params.domain_id as string, + }); + + return note; + }, + }, + { + name: "notes.update", + description: "Update a note", + inputSchema: { + type: "object", + properties: { + note_id: { type: "string" }, + title: { type: "string" }, + content: { type: "string" }, + }, + required: ["note_id"], + }, + handler: async (params, auth) => { + const updateData: Record = { updatedAt: new Date() }; + if (params.title !== undefined) updateData.title = params.title; + if (params.content !== undefined) updateData.content = params.content; + + const [note] = await db.update(notes) + .set(updateData) + .where(and(eq(notes.id, params.note_id as string), isNull(notes.deletedAt))) + .returning(); + + if (!note) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Note not found"); + + await recordActivity({ + actor: auth.userName, + action: "updated", + entityType: "note", + entityId: note.id, + workspaceId: note.domainId, + }); + + return note; + }, + }, + { + name: "notes.search", + description: "Search notes by title or content", + inputSchema: { + type: "object", + properties: { + domain_id: { type: "string" }, + query: { type: "string" }, + }, + required: ["domain_id", "query"], + }, + handler: async (params) => { + const query = params.query as string; + const items = await db.select() + .from(notes) + .where(and( + eq(notes.domainId, params.domain_id as string), + isNull(notes.deletedAt), + or(ilike(notes.title, `%${query}%`), ilike(notes.content ?? sql``, `%${query}%`)) + )) + .orderBy(desc(notes.updatedAt)) + .limit(20); + return { items }; + }, + }, + { + name: "domains.list", + description: "List domains/workspaces", + inputSchema: { type: "object", properties: {} }, + handler: async () => { + const items = await db.select().from(domains).orderBy(asc(domains.name)); + return { items }; + }, + }, + { + name: "domains.create", + description: "Create a new domain/workspace", + inputSchema: { + type: "object", + properties: { + name: { type: "string" }, + slug: { type: "string" }, + color: { type: "string" }, + }, + required: ["name", "slug"], + }, + handler: async (params, auth) => { + const [domain] = await db.insert(domains).values({ + name: params.name as string, + slug: params.slug as string, + color: (params.color as string) ?? null, + }).returning(); + + await recordActivity({ + actor: auth.userName, + action: "created", + entityType: "domain", + entityId: domain.id, + workspaceId: domain.id, + }); + + return domain; + }, + }, + { + name: "search.query", + description: "Full-text search across entities", + inputSchema: { + type: "object", + properties: { + domain_id: { type: "string" }, + query: { type: "string" }, + types: { type: "array", items: { type: "string" }, description: "Entity types: tasks, notes, projects, habits" }, + limit: { type: "number", default: 20 }, + }, + required: ["domain_id", "query"], + }, + handler: async (params) => { + const query = params.query as string; + const domainId = params.domain_id as string; + const types = (params.types as string[]) || ["tasks", "notes", "projects", "habits"]; + const limit = Math.min(Number(params.limit) || 20, 50); + const results: Record = {}; + + if (types.includes("tasks")) { + results.tasks = await db.select({ id: tasks.id, title: tasks.title, status: tasks.status, priority: tasks.priority }).from(tasks) + .where(and(eq(tasks.domainId, domainId), isNull(tasks.deletedAt), ilike(tasks.title, `%${query}%`))).limit(limit); + } + if (types.includes("notes")) { + results.notes = await db.select({ id: notes.id, title: notes.title }).from(notes) + .where(and(eq(notes.domainId, domainId), isNull(notes.deletedAt), ilike(notes.title, `%${query}%`))).limit(limit); + } + if (types.includes("projects")) { + results.projects = await db.select({ id: projects.id, name: projects.name, status: projects.status }).from(projects) + .where(and(eq(projects.domainId, domainId), isNull(projects.deletedAt), ilike(projects.name, `%${query}%`))).limit(limit); + } + if (types.includes("habits")) { + results.habits = await db.select({ id: habits.id, name: habits.name, frequency: habits.frequency }).from(habits) + .where(and(eq(habits.domainId, domainId), isNull(habits.deletedAt), ilike(habits.name, `%${query}%`))).limit(limit); + } + + return results; + }, + }, + { + name: "activity.list", + description: "List recent activity feed entries", + inputSchema: { + type: "object", + properties: { + workspace_id: { type: "string" }, + limit: { type: "number", default: 20 }, + offset: { type: "number", default: 0 }, + }, + required: ["workspace_id"], + }, + handler: async (params) => { + const items = await db.select() + .from(activityFeed) + .where(eq(activityFeed.workspaceId, params.workspace_id as string)) + .orderBy(desc(activityFeed.createdAt)) + .limit(Math.min(Number(params.limit) || 20, 100)) + .offset(Number(params.offset) || 0); + return { items }; + }, + }, +]; + +// ── Error helper ───────────────────────────────────────────────────────────────── + +class JsonRpcErrorResponse extends Error { + constructor(public code: number, message: string, public data?: unknown) { + super(message); + this.name = "JsonRpcErrorResponse"; + } +} + +function makeError(code: number, message: string, data?: unknown): JsonRpcResponse { + return { jsonrpc: "2.0", error: { code, message, data }, id: null }; +} + +function makeResult(result: unknown, id: string | number | null): JsonRpcResponse { + return { jsonrpc: "2.0", result, id }; +} + +async function handleRequest(body: JsonRpcRequest, auth: { userId: string; userName: string }): Promise { + const { method, params, id } = body; + + // MCP initialize + if (method === "initialize") { + return makeResult({ + protocolVersion: "2024-11-05", + capabilities: { + tools: {}, + resources: {}, + }, + serverInfo: { + name: "project-e", + version: "1.0.0", + }, + }, id); + } + + // MCP tools/list + if (method === "tools/list") { + return makeResult({ + tools: tools.map(t => ({ + name: t.name, + description: t.description, + inputSchema: t.inputSchema, + })), + }, id); + } + + // MCP tools/call + if (method === "tools/call") { + const callParams = params as { name?: string; arguments?: Record } | undefined; + if (!callParams?.name) { + return makeError(JSONRPC_INVALID_PARAMS, "Missing tool name", id); + } + + const tool = tools.find(t => t.name === callParams.name); + if (!tool) { + return makeError(JSONRPC_METHOD_NOT_FOUND, `Unknown tool: ${callParams.name}`, id); + } + + try { + const result = await tool.handler(callParams.arguments || {}, auth); + return makeResult({ content: [{ type: "text", text: JSON.stringify(result) }] }, id); + } catch (error) { + if (error instanceof JsonRpcErrorResponse) { + return makeError(error.code, error.message, error.data); + } + console.error(`[MCP] Tool ${callParams.name} error:`, error); + return makeError(JSONRPC_INTERNAL_ERROR, error instanceof Error ? error.message : "Internal error", id); + } + } + + // MCP resources/list + if (method === "resources/list") { + return makeResult({ + resources: [ + { + uri: "project-e://tasks", + name: "Tasks", + description: "Access to task entities", + mimeType: "application/json", + }, + { + uri: "project-e://notes", + name: "Notes", + description: "Access to note entities", + mimeType: "application/json", + }, + { + uri: "project-e://projects", + name: "Projects", + description: "Access to project entities", + mimeType: "application/json", + }, + { + uri: "project-e://habits", + name: "Habits", + description: "Access to habit entities", + mimeType: "application/json", + }, + ], + }, id); + } + + // MCP resources/read + if (method === "resources/read") { + const readParams = params as { uri?: string } | undefined; + if (!readParams?.uri) { + return makeError(JSONRPC_INVALID_PARAMS, "Missing resource URI", id); + } + return makeResult({ + contents: [ + { + uri: readParams.uri, + mimeType: "application/json", + text: JSON.stringify({ message: `Resource ${readParams.uri} accessed. Use tools/call for data operations.` }), + }, + ], + }, id); + } + + // Legacy server/discover + if (method === "server/discover") { + return makeResult({ + name: "project-e", + version: "1.0.0", + capabilities: { tools: {} }, + tools: tools.map(t => ({ + name: t.name, + description: t.description, + inputSchema: t.inputSchema, + })), + }, id); + } + + return makeError(JSONRPC_METHOD_NOT_FOUND, `Unknown method: ${method}`, id); +} + +// ── Route handler ──────────────────────────────────────────────────────────────── + +mcpRoutes.post("/", async (c) => { + const auth = await authenticateApiKey(c); + if (!auth) { + return c.json( + { jsonrpc: "2.0", error: { code: -32001, message: "Unauthorized. Provide a valid API key in Authorization: Bearer ***" }, id: null }, + 401 + ); + } + + let body: JsonRpcRequest; + try { + body = await c.req.json(); + } catch { + return c.json(makeError(JSONRPC_PARSE_ERROR, "Parse error: invalid JSON"), 400); + } + + if (!body || body.jsonrpc !== "2.0" || !body.method) { + return c.json(makeError(JSONRPC_INVALID_REQUEST, "Invalid Request: must be valid JSON-RPC 2.0 with method"), 400); + } + + const response = await handleRequest(body, auth); + return c.json(response); +}); + +mcpRoutes.get("/", async (c) => { + return c.json( + makeError(JSONRPC_METHOD_NOT_FOUND, "MCP server only accepts POST requests"), + 405 + ); }); diff --git a/apps/api/src/routes/realtime.ts b/apps/api/src/routes/realtime.ts index 3f5ceb0..daa66a1 100644 --- a/apps/api/src/routes/realtime.ts +++ b/apps/api/src/routes/realtime.ts @@ -1,19 +1,71 @@ import { Hono } from "hono"; -import { stream } from "hono/streaming"; +import postgres from "postgres"; export const realtimeRoutes = new Hono(); -// GET /api/realtime — SSE endpoint stub -realtimeRoutes.get("/realtime", (c) => { - return stream(c, async (stream) => { - c.header("Content-Type", "text/event-stream"); - c.header("Cache-Control", "no-cache"); - c.header("Connection", "keep-alive"); - await stream.write("data: {\"event\":\"connected\"}\n\n"); - // Keep connection open - while (true) { - await stream.write(": heartbeat\n\n"); - await stream.sleep(30000); - } +// GET /api/realtime — SSE endpoint backed by PostgreSQL LISTEN/NOTIFY +realtimeRoutes.get("/realtime", async (c) => { + const user = c.get("user"); + if (!user) { + return c.json({ error: "Unauthorized" }, 401); + } + + const url = new URL(c.req.url); + const workspaceId = url.searchParams.get("workspace_id"); + + const encoder = new TextEncoder(); + const listener = postgres(process.env.DATABASE_URL!, { max: 1 }); + let unlisten: (() => Promise) | undefined; + let keepalive: ReturnType | undefined; + + const stream = new ReadableStream({ + async start(controller) { + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ type: "connected", workspace_id: workspaceId })}\n\n` + ) + ); + + const subscription = await listener.listen("project_e_events", (payload) => { + try { + const event = JSON.parse(payload) as { + type: string; + action: string; + id: string; + workspace_id?: string; + }; + + if (workspaceId && event.workspace_id !== workspaceId) { + return; + } + + controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + } catch { + // Ignore malformed notifications + } + }); + unlisten = subscription.unlisten; + + keepalive = setInterval(() => { + try { + controller.enqueue(encoder.encode(":ping\n\n")); + } catch { + if (keepalive) clearInterval(keepalive); + } + }, 30000); + }, + + async cancel() { + if (keepalive) clearInterval(keepalive); + await unlisten?.(); + await listener.end({ timeout: 5 }); + }, }); + + c.header("Content-Type", "text/event-stream"); + c.header("Cache-Control", "no-cache"); + c.header("Connection", "keep-alive"); + c.header("X-Accel-Buffering", "no"); + + return c.newResponse(stream); }); diff --git a/bun.lock b/bun.lock index 6b8e79e..0c2d9bf 100644 --- a/bun.lock +++ b/bun.lock @@ -20,8 +20,10 @@ "version": "0.1.0", "dependencies": { "@project-e/db": "^0.1.0", + "bcryptjs": "^2.4.3", "drizzle-orm": "^0.45.2", "hono": "^4.6.0", + "jose": "^5.9.6", "postgres": "^3.4.9", }, "devDependencies": { @@ -1043,7 +1045,7 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.11.9", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-cp447VUsGS07+n1Dqf7YSQ8maeJrjEhaDxTm1ZefbqDtypHBC5GzGMQbklR6IPR13Y8OAJRHZWEMtZipJLCttg=="], - "bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="], + "bcryptjs": ["bcryptjs@2.4.3", "", {}, "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ=="], "bezier-js": ["bezier-js@6.1.4", "", {}, "sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg=="], @@ -1607,7 +1609,7 @@ "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], - "jose": ["jose@6.2.6", "", {}, "sha512-HwMtbJjMw8rC8dUTwCNilHJD+fxTeKM3JV1eprSmTjS41qwXSSt6exJXgyPK1QOu0jB9eDYLESRDkB3qaT3jnw=="], + "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -2239,12 +2241,16 @@ "@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "@modelcontextprotocol/sdk/jose": ["jose@6.2.6", "", {}, "sha512-HwMtbJjMw8rC8dUTwCNilHJD+fxTeKM3JV1eprSmTjS41qwXSSt6exJXgyPK1QOu0jB9eDYLESRDkB3qaT3jnw=="], + "@modelcontextprotocol/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "@next/eslint-plugin-next/fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="], "@project-e/web/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@project-e/web-legacy/bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="], + "@project-e/web-legacy/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "@tailwindcss/node/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],