import { oidcAuthMiddleware, processOAuthCallback, revokeSession, } from "@hono/oidc-auth"; import { Hono } from "hono"; import { serveStatic } from "hono/bun"; import { cors } from "hono/cors"; import { db as prodDb } from "../db/index.ts"; import { seedDefaults } from "../db/seed.ts"; import { mcpRoutes } from "./mcp/index.ts"; import { requireAuth } from "./middleware/auth.ts"; import { createRateLimit } from "./middleware/rateLimit.ts"; import { accountRoutes } from "./routes/account.ts"; import { authRoutes } from "./routes/auth.ts"; import { categoryRoutes } from "./routes/categories.ts"; import { communityPriceRoutes } from "./routes/community-prices.ts"; import { discoveryRoutes } from "./routes/discovery.ts"; import { exchangeRateRoutes } from "./routes/exchange-rates.ts"; import { globalItemRoutes } from "./routes/global-items.ts"; import { imageRoutes } from "./routes/images.ts"; import { itemRoutes } from "./routes/items.ts"; import { manufacturerRoutes } from "./routes/manufacturers.ts"; import { marketPriceRoutes } from "./routes/market-prices.ts"; import { oauthRoutes, wellKnownRoute } from "./routes/oauth.ts"; import { onboardingRoutes } from "./routes/onboarding.ts"; import { profileRoutes } from "./routes/profiles.ts"; import { settingsRoutes } from "./routes/settings.ts"; import { setupRoutes } from "./routes/setups.ts"; import { tagRoutes } from "./routes/tags.ts"; import { threadRoutes } from "./routes/threads.ts"; import { totalRoutes } from "./routes/totals.ts"; import { getSetupItemById, getSetupWithItemsById, } from "./services/setup.service.ts"; import { validateShareToken } from "./services/share.service.ts"; import { withImageUrl, withImageUrls } from "./services/storage.service.ts"; // Seed default data on startup await seedDefaults(); // OIDC connectivity pre-check: verify Logto discovery is reachable at startup // This surfaces network/config errors early in logs rather than hiding them as "Invalid session" const oidcIssuer = process.env.OIDC_ISSUER; if (oidcIssuer) { const discoveryUrl = `${oidcIssuer}/.well-known/openid-configuration`; fetch(discoveryUrl) .then(async (res) => { if (!res.ok) { console.error( `[OIDC] Discovery endpoint returned HTTP ${res.status}: ${discoveryUrl}`, ); } else { console.log(`[OIDC] Discovery endpoint reachable: ${discoveryUrl}`); } }) .catch((err) => { console.error( `[OIDC] Discovery endpoint unreachable: ${discoveryUrl}`, err, ); console.error( "[OIDC] This will cause 'Invalid session' errors on /login — check network connectivity to Logto", ); }); } else { console.warn("[OIDC] OIDC_ISSUER is not set — OIDC authentication will fail"); } const app = new Hono(); // Centralized error handler app.onError((err, c) => { console.error(`[${c.req.method}] ${c.req.path}:`, err); // HTTPException has a getResponse() method — use it to preserve the original status/message if ("getResponse" in err && typeof (err as any).getResponse === "function") { return (err as any).getResponse(); } const message = process.env.NODE_ENV === "production" ? "Internal server error" : err.message || "Internal server error"; return c.json({ error: message }, 500); }); // Health check app.get("/api/health", (c) => { return c.json({ status: "ok" }); }); // ── OIDC Browser Auth (top-level, before /api/* middleware) ─────────── // In dev mode, strip Secure flag from OIDC cookies so they work over HTTP if (process.env.NODE_ENV !== "production") { app.use("*", async (c, next) => { await next(); const setCookies = c.res.headers.getSetCookie?.() ?? []; if (setCookies.length > 0) { c.res.headers.delete("Set-Cookie"); for (const cookie of setCookies) { c.res.headers.append("Set-Cookie", cookie.replace(/;\s*Secure/gi, "")); } } }); } app.get("/login", oidcAuthMiddleware(), async (c) => c.redirect("/")); app.get("/callback", async (c) => processOAuthCallback(c)); app.get("/logout", async (c) => { await revokeSession(c); const issuer = process.env.OIDC_ISSUER; const postLogoutRedirect = new URL("/", c.req.url).origin; if (issuer) { const clientId = process.env.OIDC_CLIENT_ID; const redirectUri = process.env.GEARBOX_URL || postLogoutRedirect; return c.redirect( `${issuer}/session/end?client_id=${encodeURIComponent(clientId || "")}&post_logout_redirect_uri=${encodeURIComponent(redirectUri)}`, ); } return c.redirect("/"); }); // CORS for OAuth and MCP endpoints (required for claude.ai browser-based flows) app.use("/.well-known/*", cors()); app.use("/oauth/*", cors()); app.use("/mcp/*", cors()); // OAuth routes (must be before /api/* middleware) app.use("/oauth/*", async (c, next) => { c.set("db", prodDb); return next(); }); app.route("/.well-known", wellKnownRoute); app.route("/oauth", oauthRoutes); // Inject production database into request context app.use("/api/*", async (c, next) => { c.set("db", prodDb); return next(); }); app.use("/s/*", async (c, next) => { c.set("db", prodDb); return next(); }); // Rate limiting for public endpoints (per D-07, D-08) const browseTier = createRateLimit(120, 60_000); const detailTier = createRateLimit(60, 60_000); // Browse endpoints — higher limit for list/search app.use("/api/discovery/*", async (c, next) => { if (c.req.method === "GET") return browseTier(c, next); return next(); }); app.use("/api/global-items", async (c, next) => { if (c.req.method === "GET" && !c.req.path.match(/^\/api\/global-items\/\d+$/)) return browseTier(c, next); return next(); }); app.use("/api/tags", async (c, next) => { if (c.req.method === "GET") return browseTier(c, next); return next(); }); // Detail endpoints — moderate limit for individual resources app.use("/api/global-items/:id", async (c, next) => { if (c.req.method === "GET") return detailTier(c, next); return next(); }); app.use("/api/setups/:id/public", async (c, next) => { if (c.req.method === "GET") return detailTier(c, next); return next(); }); app.use("/api/users/:id/profile", async (c, next) => { if (c.req.method === "GET") return detailTier(c, next); return next(); }); // Shared setup access via token (no auth required) app.get("/api/shared/:token", async (c) => { const db = c.get("db"); const token = c.req.param("token"); const result = await validateShareToken(db, token); if (!result) return c.json({ error: "Not found" }, 404); const setup = await getSetupWithItemsById(db, result.setupId); if (!setup) return c.json({ error: "Not found" }, 404); const enrichedItems = await withImageUrls(setup.items); return c.json({ ...setup, items: enrichedItems }); }); // Shared setup item detail via token (no auth required) app.get("/api/shared/:token/items/:itemId", async (c) => { const db = c.get("db"); const token = c.req.param("token"); const itemId = Number(c.req.param("itemId")); if (!itemId || Number.isNaN(itemId)) return c.json({ error: "Invalid item ID" }, 400); const result = await validateShareToken(db, token); if (!result) return c.json({ error: "Not found" }, 404); const item = await getSetupItemById(db, result.setupId, itemId); if (!item) return c.json({ error: "Not found" }, 404); const enriched = await withImageUrl(item); return c.json(enriched); }); // Public setup item detail (no auth required — setup must be public) app.get("/api/setups/:setupId/items/:itemId/public", async (c) => { const db = c.get("db"); const setupId = Number(c.req.param("setupId")); const itemId = Number(c.req.param("itemId")); if (!setupId || !itemId || Number.isNaN(setupId) || Number.isNaN(itemId)) return c.json({ error: "Invalid ID" }, 400); // Verify setup is public const { setups } = await import("../db/schema.ts"); const { eq } = await import("drizzle-orm"); const [setup] = await db .select({ visibility: setups.visibility }) .from(setups) .where(eq(setups.id, setupId)); if (!setup || setup.visibility !== "public") return c.json({ error: "Not found" }, 404); const item = await getSetupItemById(db, setupId, itemId); if (!item) return c.json({ error: "Not found" }, 404); const enriched = await withImageUrl(item); return c.json(enriched); }); // Short share URL redirect (no auth required — before SPA catch-all) app.get("/s/:token", async (c) => { const db = c.get("db"); const token = c.req.param("token"); const result = await validateShareToken(db, token); if (!result) return c.redirect("/", 302); return c.redirect(`/setups/${result.setupId}?share=${token}`, 302); }); // Auth middleware for all data routes (userId must be available for per-user scoping) app.use("/api/*", async (c, next) => { // Skip auth routes — they handle their own auth if (c.req.path.startsWith("/api/auth")) return next(); // Skip health check if (c.req.path === "/api/health") return next(); // Skip public profile endpoint (GET /api/users/:id/profile) if (/^\/api\/users\/\d+\/profile$/.test(c.req.path) && c.req.method === "GET") return next(); // Skip public setup view (GET /api/setups/:id/public) if (/^\/api\/setups\/\d+\/public$/.test(c.req.path) && c.req.method === "GET") return next(); // Skip public setup item view (GET /api/setups/:id/items/:id/public) if ( /^\/api\/setups\/\d+\/items\/\d+\/public$/.test(c.req.path) && c.req.method === "GET" ) return next(); // Skip public tags endpoint (GET /api/tags) if (c.req.path.startsWith("/api/tags") && c.req.method === "GET") return next(); // Skip shared setup access (GET /api/shared/:token) if (c.req.path.startsWith("/api/shared/") && c.req.method === "GET") return next(); // Skip public discovery endpoints (GET /api/discovery/*) if (c.req.path.startsWith("/api/discovery") && c.req.method === "GET") return next(); // Skip public global-items endpoint (GET /api/global-items) if (c.req.path.startsWith("/api/global-items") && c.req.method === "GET") return next(); // Skip public exchange rates endpoint (GET /api/exchange-rates) if (c.req.path.startsWith("/api/exchange-rates") && c.req.method === "GET") return next(); // Skip public market prices read endpoint (GET /api/market-prices) if (c.req.path.startsWith("/api/market-prices") && c.req.method === "GET") return next(); // Skip public community prices read endpoint (GET /api/community-prices) if (c.req.path.startsWith("/api/community-prices") && c.req.method === "GET") return next(); // All other methods require auth for userId resolution return requireAuth(c, next); }); // API routes app.route("/api/account", accountRoutes); app.route("/api/auth", authRoutes); app.route("/api/items", itemRoutes); app.route("/api/categories", categoryRoutes); app.route("/api/totals", totalRoutes); app.route("/api/images", imageRoutes); app.route("/api/settings", settingsRoutes); app.route("/api/threads", threadRoutes); app.route("/api/users", profileRoutes); app.route("/api/setups", setupRoutes); app.route("/api/discovery", discoveryRoutes); app.route("/api/global-items", globalItemRoutes); app.route("/api/manufacturers", manufacturerRoutes); app.route("/api/onboarding", onboardingRoutes); app.route("/api/tags", tagRoutes); app.route("/api/exchange-rates", exchangeRateRoutes); app.route("/api/market-prices", marketPriceRoutes); app.route("/api/community-prices", communityPriceRoutes); // MCP server (conditionally mounted) if (process.env.GEARBOX_MCP !== "false") { app.route("/mcp", mcpRoutes); } // Serve Vite-built SPA in production if (process.env.NODE_ENV === "production") { app.use("/*", serveStatic({ root: "./dist/client" })); app.get("*", serveStatic({ path: "./dist/client/index.html" })); } export default { port: 3000, fetch: app.fetch }; export { app };