- 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>
314 lines
11 KiB
TypeScript
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 };
|