mirror of
https://github.com/willchen96/mike.git
synced 2026-07-24 23:41:04 +02:00
Ports the backend route-level integration suites from the amal66 fork (index: Open-Legal-Products/mike#205), adapted from the fork's apps/api modules/services layout to this repo's monolithic backend/src/routes/*.ts layout. app/index split (the only production change; mechanical, zero behavior change): backend/src/index.ts previously built the express app and called app.listen at the bottom. Everything except the listen call moved verbatim into backend/src/app.ts, which now exports `app` (same middleware order, same routes, same rate-limiter setup, dotenv/config still imported first). index.ts is now a tiny entry that imports { app } and calls listen with the same log line. `npm run build` still emits dist/index.js and the `start` script is unchanged. New suites under backend/src/__tests__/integration/ (94 tests): - health.test.ts (5): /health, requireAuth 401 paths, 404 fallthrough - chat.routes.test.ts (6): POST /chat validation + SSE happy/error paths - projects.routes.test.ts (18): overview/create/detail/patch/delete, sharing normalisation, ownership guards - projectChat.routes.test.ts (3): project access guard + SSE paths - tabular.routes.test.ts (31): review CRUD, access guards, document-access filtering, missing_api_key guards - user.routes.test.ts (27): profile, API-key crypto boundary, MFA guards, export/deletion endpoints - documentsUpload.routes.test.ts (4): upload validation + download-zip bounds/access - access.supabase.test.ts (1) + stack.supabase.test.ts (4): gated on SUPABASE_TEST_URL / SUPABASE_TEST_SERVICE_ROLE_KEY (stack suite also needs SUPABASE_TEST_ANON_KEY); describe.skip otherwise - scripts/test-stack.sh + `npm run test:stack`: reads a running `supabase status -o json` and runs the gated suites Adds supertest + @types/supertest as devDependencies. Dropped relative to the fork (subjects that do not exist in this repo): - orgs.routes, credits.concurrency.supabase, dmsConnectors suites (fork-only features) - all credit-reservation cases (429 CREDIT_LIMIT_EXCEEDED, reserve-then-refund) in chat/projectChat: no credit system here - health /ready case: no /ready endpoint here - org-membership project access case: no org model here - upload magic-byte validation cases: this repo validates extension only (the fork adds content sniffing; replaced with a missing-file 400 case) - download-zip 50-document cap case: no cap here (replaced with a no-accessible-documents 404 case) - stack.supabase PUBLIC_TABLES updated to this repo's schema; the fork's credit-RPC coverage is n/a Verified locally: backend `npm test` -> 8 files passed, 2 skipped; 106 tests passed, 5 skipped (gated suites skip without env). `npm run build` passes. The gated suites were additionally run against a live local Supabase stack: 2 files, 5/5 tests passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
161 lines
4.8 KiB
TypeScript
161 lines
4.8 KiB
TypeScript
import "dotenv/config";
|
|
import express from "express";
|
|
import cors from "cors";
|
|
import helmet from "helmet";
|
|
import rateLimit from "express-rate-limit";
|
|
import { chatRouter } from "./routes/chat";
|
|
import { projectsRouter } from "./routes/projects";
|
|
import { projectChatRouter } from "./routes/projectChat";
|
|
import { documentsRouter } from "./routes/documents";
|
|
import { libraryRouter } from "./routes/library";
|
|
import { tabularRouter } from "./routes/tabular";
|
|
import { workflowsRouter } from "./routes/workflows";
|
|
import { userRouter } from "./routes/user";
|
|
import { downloadsRouter } from "./routes/downloads";
|
|
import { caseLawRouter } from "./routes/caseLaw";
|
|
|
|
export const app = express();
|
|
const isProduction = process.env.NODE_ENV === "production";
|
|
|
|
function envInt(name: string, fallback: number): number {
|
|
const raw = process.env[name];
|
|
if (!raw) return fallback;
|
|
const parsed = Number.parseInt(raw, 10);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
}
|
|
|
|
function minutes(value: number): number {
|
|
return value * 60 * 1000;
|
|
}
|
|
|
|
function hours(value: number): number {
|
|
return minutes(value * 60);
|
|
}
|
|
|
|
function makeLimiter(options: {
|
|
windowMs: number;
|
|
max: number;
|
|
message?: string;
|
|
}) {
|
|
return rateLimit({
|
|
windowMs: options.windowMs,
|
|
max: options.max,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
skip: (req) => req.method === "OPTIONS",
|
|
message: {
|
|
detail:
|
|
options.message ?? "Too many requests. Please try again later.",
|
|
},
|
|
});
|
|
}
|
|
|
|
const generalLimiter = makeLimiter({
|
|
windowMs: minutes(envInt("RATE_LIMIT_GENERAL_WINDOW_MINUTES", 15)),
|
|
max: envInt("RATE_LIMIT_GENERAL_MAX", 300),
|
|
});
|
|
|
|
const chatLimiter = makeLimiter({
|
|
windowMs: minutes(envInt("RATE_LIMIT_CHAT_WINDOW_MINUTES", 15)),
|
|
max: envInt("RATE_LIMIT_CHAT_MAX", 30),
|
|
message: "Too many chat requests. Please try again later.",
|
|
});
|
|
|
|
const chatCreateLimiter = makeLimiter({
|
|
windowMs: minutes(envInt("RATE_LIMIT_CHAT_CREATE_WINDOW_MINUTES", 15)),
|
|
max: envInt("RATE_LIMIT_CHAT_CREATE_MAX", 60),
|
|
});
|
|
|
|
const uploadLimiter = makeLimiter({
|
|
windowMs: hours(envInt("RATE_LIMIT_UPLOAD_WINDOW_HOURS", 1)),
|
|
max: envInt("RATE_LIMIT_UPLOAD_MAX", 50),
|
|
message: "Too many upload requests. Please try again later.",
|
|
});
|
|
|
|
const exportLimiter = makeLimiter({
|
|
windowMs: hours(envInt("RATE_LIMIT_EXPORT_WINDOW_HOURS", 1)),
|
|
max: envInt("RATE_LIMIT_EXPORT_MAX", 10),
|
|
message: "Too many export requests. Please try again later.",
|
|
});
|
|
|
|
const dataDeleteLimiter = makeLimiter({
|
|
windowMs: hours(envInt("RATE_LIMIT_DATA_DELETE_WINDOW_HOURS", 1)),
|
|
max: envInt("RATE_LIMIT_DATA_DELETE_MAX", 20),
|
|
message: "Too many data deletion requests. Please try again later.",
|
|
});
|
|
|
|
function jsonLimitForPath(path: string): string {
|
|
return "50mb";
|
|
}
|
|
|
|
app.disable("x-powered-by");
|
|
app.set("trust proxy", envInt("TRUST_PROXY_HOPS", 1));
|
|
|
|
app.use(
|
|
helmet({
|
|
contentSecurityPolicy: {
|
|
directives: {
|
|
defaultSrc: ["'none'"],
|
|
baseUri: ["'none'"],
|
|
frameAncestors: ["'none'"],
|
|
},
|
|
},
|
|
crossOriginEmbedderPolicy: false,
|
|
hsts: isProduction
|
|
? {
|
|
maxAge: 15552000,
|
|
includeSubDomains: true,
|
|
}
|
|
: false,
|
|
referrerPolicy: { policy: "no-referrer" },
|
|
}),
|
|
);
|
|
|
|
app.use(
|
|
cors({
|
|
origin: process.env.FRONTEND_URL ?? "http://localhost:3000",
|
|
credentials: true,
|
|
}),
|
|
);
|
|
|
|
app.use(generalLimiter);
|
|
|
|
app.post("/chat", chatLimiter);
|
|
app.post("/projects/:projectId/chat", chatLimiter);
|
|
app.post("/tabular-review/:reviewId/chat", chatLimiter);
|
|
app.post("/tabular-review/:reviewId/generate", chatLimiter);
|
|
app.post("/chat/create", chatCreateLimiter);
|
|
app.post("/chat/:chatId/generate-title", chatCreateLimiter);
|
|
app.post("/single-documents", uploadLimiter);
|
|
app.post("/library/:kind/documents", uploadLimiter);
|
|
app.post("/single-documents/:documentId/versions", uploadLimiter);
|
|
app.put(
|
|
"/single-documents/:documentId/versions/:versionId/file",
|
|
uploadLimiter,
|
|
);
|
|
app.post("/projects/:projectId/documents", uploadLimiter);
|
|
app.get("/user/export", exportLimiter);
|
|
app.get("/user/chats/export", exportLimiter);
|
|
app.get("/user/tabular-reviews/export", exportLimiter);
|
|
app.delete("/user/account", dataDeleteLimiter);
|
|
app.delete("/user/chats", dataDeleteLimiter);
|
|
app.delete("/user/projects", dataDeleteLimiter);
|
|
app.delete("/user/tabular-reviews", dataDeleteLimiter);
|
|
|
|
app.use((req, res, next) =>
|
|
express.json({ limit: jsonLimitForPath(req.path) })(req, res, next),
|
|
);
|
|
|
|
app.use("/chat", chatRouter);
|
|
app.use("/projects", projectsRouter);
|
|
app.use("/projects/:projectId/chat", projectChatRouter);
|
|
app.use("/single-documents", documentsRouter);
|
|
app.use("/library", libraryRouter);
|
|
app.use("/tabular-review", tabularRouter);
|
|
app.use("/workflows", workflowsRouter);
|
|
app.use("/user", userRouter);
|
|
app.use("/users", userRouter);
|
|
app.use("/download", downloadsRouter);
|
|
app.use("/case-law", caseLawRouter);
|
|
|
|
app.get("/health", (_req, res) => res.json({ ok: true }));
|