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 { discoveryRoutes } from "./routes/discovery.ts"; import { globalItemRoutes } from "./routes/global-items.ts"; import { imageRoutes } from "./routes/images.ts"; import { itemRoutes } from "./routes/items.ts"; import { oauthRoutes, wellKnownRoute } from "./routes/oauth.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"; // 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); return c.redirect("/login"); }); // 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(); }); // 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(); }); // 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 tags endpoint (GET /api/tags) if (c.req.path.startsWith("/api/tags") && 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(); // 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/tags", tagRoutes); // 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 };