Files
GearBox/src/server/index.ts
Jean-Luc Makiola e8207a33f9 feat(28-01): add account management routes for password, email, and deletion
Creates /api/account routes with password change (verifies current first),
email update, has-password check, and account deletion with public setup
anonymization. Adds Zod validation schemas and registers routes in index.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:47:17 +02:00

209 lines
7.1 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 { 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 };