Files
GearBox/src/server/index.ts
Jean-Luc Makiola 4ccbb2b070
Some checks failed
CI / ci (push) Failing after 1m44s
CI / e2e (push) Has been skipped
CI / deploy (push) Has been skipped
fix: wire catalog add buttons, fix Trans bold rendering, lint cleanup
- CatalogSearchOverlay: replace handleAddStub with real openAddToCollection/openAddToThread routing based on catalogSearchMode
- ConfirmDialog + __root.tsx: swap t() for Trans component on deleteItemMessage, deleteCandidateMessage, pickWinnerMessage — fixes <bold> rendering as literal text
- Biome format pass: fix 23 lint/format errors across scripts, services, tests
- Planning: mark all UAT and verification gaps resolved for phases 07, 11, 16, 20, 21, 22, 24, 32, 34; close debug sessions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 15:36:16 +02:00

314 lines
11 KiB
TypeScript

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 };