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