Skip to content

Commit 2c92dbe

Browse files
amal66claude
andcommitted
fix(backend): contain async route errors in the un-modularized routes
Express 4 does not forward async handler rejections, and the routes that open-legal-products#295 left outside modules/ were unguarded — a throw in any of them meant an unhandled rejection and a socket held open until client timeout. Only the workflows router defended itself. Its asyncRoute wrapper is now a shared middleware (middleware/asyncRoute.ts) applied to audit, quickActions, sourceDocuments, wordChat and workflowAddons, so a rejection reaches app.ts's handleUnhandledError instead of nothing. routerErrorHandler now only attributes the failure to a router in the log and delegates the response to handleUnhandledError. Answering with a router-specific 500 body would have contradicted the internal-error contract main established: body-parser's 400/413 keep their own status and code, everything else is the opaque {code:"internal_error"} body. Also fixed in those route files, where errors were being silently converted to success or not-found: - quickActions: DELETE returned 204 with no row matched (now selects the deleted ids and 404s when none); DB failures on the workflow lookup read as 404 (now 500, via the now-exported workflows.service.resolveWorkflowAccess instead of a drifted private copy). - workflowAddons: the import response hand-rebuilt the workflow shape and had drifted from GET /workflows/:id on four fields — now uses the exported withDatabaseWorkflow; a failed add-on lookup answered 404 (now 500). - audit: lib/auditExport's private project-access helper duplicated lib/access.listAccessibleProjectIds and had drifted from it (it re-counted the caller's own shared projects and used a different `contains` encoding). - sourceDocuments: a missing or rejected CourtListener token answered 502, sending the user to check a service that was fine; credential failures now answer 400 with a fixed message that never echoes the upstream body. The history page's surface filter learns the "word" label, so the durable Word chat audit rows open-legal-products#294 introduced (surface "word") are filterable rather than showing a raw key. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a8ac88c commit 2c92dbe

13 files changed

Lines changed: 830 additions & 733 deletions

File tree

backend/src/app.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,4 +300,10 @@ app.get("/manifest-signing-key", (_req, res) => {
300300
}
301301
});
302302

303+
// Terminal error handler. Routers that mount their own error middleware answer
304+
// first; anything they re-raise (or that escapes a router without one) lands
305+
// here instead of Express's default handler, which would leak the stack trace
306+
// in a non-production environment. Must stay last: Express 4 only reaches an
307+
// error handler registered after the middleware that failed, and asyncRoute is
308+
// what gets rejected promises here at all.
303309
app.use(handleUnhandledError);

backend/src/lib/__tests__/auditExport.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@ function makeDb(events: Record<string, unknown>[], error?: { message: string })
1818
ilike: () => b,
1919
gte: () => b,
2020
lte: () => b,
21+
// listAccessibleProjectIds (lib/access) scopes the shared-project
22+
// lookup with .filter("shared_with","cs",...).neq(...); .contains
23+
// stays for any caller still using the older encoding.
2124
contains: () => b,
25+
filter: () => b,
26+
neq: () => b,
2227
order: () => b,
2328
range: (from: number, to: number) => {
2429
ranges.push([from, to]);
@@ -106,7 +111,12 @@ function makeProfileDb(
106111
ilike: () => b,
107112
gte: () => b,
108113
lte: () => b,
114+
// listAccessibleProjectIds (lib/access) scopes the shared-project
115+
// lookup with .filter("shared_with","cs",...).neq(...); .contains
116+
// stays for any caller still using the older encoding.
109117
contains: () => b,
118+
filter: () => b,
119+
neq: () => b,
110120
order: () => b,
111121
in: () => {
112122
profilesQueried = true;

backend/src/lib/auditExport.ts

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
// router would drag in the whole HTTP surface.
77

88
import type { createServerSupabase } from "./supabase";
9+
import { listAccessibleProjectIds } from "./access";
910
import { normalizeDisplayName } from "./userLookup";
1011

1112
type Db = ReturnType<typeof createServerSupabase>;
@@ -18,25 +19,6 @@ export const AUDIT_EXPORT_LIMIT = 2000;
1819
const MAX_PAGE = 100_000;
1920
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
2021

21-
export async function accessibleProjectIds(
22-
db: Db,
23-
userId: string,
24-
email: string | undefined,
25-
): Promise<string[]> {
26-
const ids = new Set<string>();
27-
const own = await db.from("projects").select("id").eq("user_id", userId);
28-
for (const row of (own.data ?? []) as { id: string }[]) ids.add(row.id);
29-
if (email) {
30-
const shared = await db
31-
.from("projects")
32-
.select("id")
33-
.contains("shared_with", [email.trim().toLowerCase()]);
34-
for (const row of (shared.data ?? []) as { id: string }[])
35-
ids.add(row.id);
36-
}
37-
return [...ids];
38-
}
39-
4022
export type AuditQuery = {
4123
q?: string;
4224
action?: string;
@@ -129,7 +111,11 @@ export async function queryEvents(
129111
q: AuditQuery,
130112
resolveDisplayNames = true,
131113
) {
132-
const projectIds = await accessibleProjectIds(db, userId, email);
114+
// Shared with the chat/document listings: one definition of "projects this
115+
// user can see" for everything that scopes a collection query. The private
116+
// copy that used to live here had already drifted (it re-counted the
117+
// caller's own shared projects and used a different `contains` encoding).
118+
const projectIds = await listAccessibleProjectIds(userId, email, db);
133119
let query = db
134120
.from("audit_events")
135121
.select(
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Shared plumbing for async Express handlers.
2+
//
3+
// Express 4 does not understand promises: an `async` handler that rejects is
4+
// invisible to the router, so the request hangs until the client or the proxy
5+
// times out and nothing is logged. `asyncRoute` forwards the rejection to
6+
// `next(err)`, where an error middleware turns it into a response.
7+
8+
import type { NextFunction, Request, Response } from "express";
9+
import { handleUnhandledError } from "./internalErrorResponse";
10+
11+
export type AsyncRoute = (req: Request, res: Response) => Promise<unknown>;
12+
13+
export function asyncRoute(handler: AsyncRoute) {
14+
return (req: Request, res: Response, next: NextFunction) => {
15+
void handler(req, res).catch(next);
16+
};
17+
}
18+
19+
// Builds a router-scoped error middleware. Express 4 identifies error
20+
// middleware purely by arity, so all four parameters must stay declared even
21+
// when a router does not use them.
22+
//
23+
// Its only job is to attribute the failure to a router in the logs; the
24+
// response itself is delegated to the same app-level boundary, so a rejection
25+
// caught here is indistinguishable on the wire from one that escaped to
26+
// app.ts — body-parser's 400/413 keep their own status and code, everything
27+
// else becomes the opaque internal_error body. A response that already started
28+
// streaming (SSE, a file download) is handed on instead: its status line is
29+
// long gone, so the only honest thing left is to let Express destroy the
30+
// connection.
31+
export function routerErrorHandler(tag: string) {
32+
return (err: unknown, req: Request, res: Response, next: NextFunction) => {
33+
if (res.headersSent) return next(err);
34+
console.error(`${tag} unhandled route error`, err);
35+
handleUnhandledError(err, req, res, next);
36+
};
37+
}

backend/src/modules/workflows/workflows.routes.ts

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
// call the service layer in workflows.service.ts, and map its typed results
33
// onto status codes and JSON responses.
44

5-
import { Router, type NextFunction, type Request, type Response } from "express";
5+
import { Router, type Request, type Response } from "express";
66
import { requireAuth } from "../../middleware/auth";
7+
import { asyncRoute, routerErrorHandler } from "../../middleware/asyncRoute";
78
import { createServerSupabase } from "../../lib/supabase";
89
import { parsePaginationQuery } from "../../lib/pagination";
910
import { normalizeSearchTerm } from "../../lib/search";
@@ -45,14 +46,6 @@ export const workflowsRouter = Router();
4546

4647
type Db = ReturnType<typeof createServerSupabase>;
4748

48-
type AsyncRoute = (req: Request, res: Response) => Promise<unknown>;
49-
50-
function asyncRoute(handler: AsyncRoute) {
51-
return (req: Request, res: Response, next: NextFunction) => {
52-
void handler(req, res).catch(next);
53-
};
54-
}
55-
5649
// Installs missing default workflows before any listing; a failure here is
5750
// terminal for the request (500 with the opaque internal-error body).
5851
async function ensureDefaultsForRequest(
@@ -513,9 +506,5 @@ workflowsRouter.post("/:workflowId/share", requireAuth, asyncRoute(async (req, r
513506
}));
514507

515508
workflowsRouter.use(
516-
(err: unknown, _req: Request, res: Response, next: NextFunction) => {
517-
if (res.headersSent) return next(err);
518-
console.error("[workflows] unhandled route error", err);
519-
res.status(500).json({ detail: "Failed to process workflow request" });
520-
},
509+
routerErrorHandler("[workflows]"),
521510
);

backend/src/modules/workflows/workflows.service.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,10 @@ function metadataFromWorkflowRecord(
211211
};
212212
}
213213

214-
function withDatabaseWorkflow(workflow: WorkflowRecord) {
214+
// Exported for the workflow-addons import route, whose 201 body must match
215+
// GET /workflows/:id. Hand-rebuilding that shape there had already drifted on
216+
// metadata.name, contributors, version and is_default.
217+
export function withDatabaseWorkflow(workflow: WorkflowRecord) {
215218
const {
216219
title: _title,
217220
type: _type,
@@ -309,7 +312,11 @@ function contributorFromName(name: unknown): WorkflowContributor {
309312
};
310313
}
311314

312-
async function resolveWorkflowAccess(
315+
// Exported for the quick-actions route, which links a quick action to a
316+
// workflow and has to apply exactly this owner-or-share rule. Its private copy
317+
// had already drifted: it treated any lookup failure as "not found" and never
318+
// consulted workflow_shares.allow_edit.
319+
export async function resolveWorkflowAccess(
313320
db: Db,
314321
workflowId: string,
315322
userId: string,

backend/src/routes/__tests__/audit.test.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import {
44
escapeLikePattern,
55
parseQuery,
66
queryEvents,
7-
accessibleProjectIds,
87
} from "../audit";
8+
import { listAccessibleProjectIds } from "../../lib/access";
99

1010
// ---------------------------------------------------------------------------
1111
// csvCell — spreadsheet formula-injection escaping (F3)
@@ -116,13 +116,14 @@ describe("parseQuery", () => {
116116
});
117117

118118
// ---------------------------------------------------------------------------
119-
// queryEvents / accessibleProjectIds — visibility scoping
119+
// queryEvents / listAccessibleProjectIds — visibility scoping
120120
// ---------------------------------------------------------------------------
121121

122122
/**
123123
* Chainable Supabase mock. `projects` select responses are keyed by whether the
124-
* query used .eq (owned) or .contains (shared). The audit_events builder
125-
* records the .or / .eq filter it was given so tests can assert scoping.
124+
* query used .eq (owned) or .filter (shared_with contains). The audit_events
125+
* builder records the .or / .eq filter it was given so tests can assert
126+
* scoping.
126127
*/
127128
function makeDb(
128129
owned: string[],
@@ -146,10 +147,11 @@ function makeDb(
146147
mode = "owned";
147148
return b;
148149
},
149-
contains: () => {
150+
filter: () => {
150151
mode = "shared";
151152
return b;
152153
},
154+
neq: () => b,
153155
then: (resolve: (v: { data: { id: string }[] }) => unknown) =>
154156
Promise.resolve({
155157
data: (mode === "owned" ? owned : shared).map((id) => ({
@@ -238,10 +240,10 @@ describe("queryEvents visibility scoping", () => {
238240
});
239241

240242
it("de-duplicates owned and shared project ids", async () => {
241-
const both = await accessibleProjectIds(
242-
makeDb(["p1", "p2"], ["p2", "p3"]).db,
243+
const both = await listAccessibleProjectIds(
243244
"u1",
244245
"u1@example.com",
246+
makeDb(["p1", "p2"], ["p2", "p3"]).db,
245247
);
246248
expect([...both].sort()).toEqual(["p1", "p2", "p3"]);
247249
});

backend/src/routes/audit.ts

Lines changed: 55 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import { Router } from "express";
66
import { requireAuth, requireMfaIfEnrolled } from "../middleware/auth";
7+
import { asyncRoute } from "../middleware/asyncRoute";
78
import { createServerSupabase } from "../lib/supabase";
89
import { sendInternalError } from "../lib/httpError";
910
import {
@@ -17,7 +18,6 @@ import {
1718
// The query/CSV helpers moved to lib/auditExport so the async "audit-csv"
1819
// export job can reuse them; re-exported here for existing importers.
1920
export {
20-
accessibleProjectIds,
2121
buildAuditCsv,
2222
csvCell,
2323
escapeLikePattern,
@@ -31,52 +31,62 @@ auditRouter.use(requireAuth);
3131

3232
const PAGE_SIZE = 50;
3333

34-
auditRouter.get("/", async (req, res) => {
35-
const userId = res.locals.userId as string;
36-
const email = res.locals.userEmail as string | undefined;
37-
const db = createServerSupabase();
38-
const parsed = parseQuery(req.query as Record<string, unknown>, PAGE_SIZE);
39-
if (!parsed.ok) return void res.status(400).json({ detail: parsed.error });
40-
const q = parsed.query;
41-
const { data, error, count } = await queryEvents(db, userId, email, q);
42-
if (error) return void sendInternalError(res, error);
43-
res.json({
44-
events: data ?? [],
45-
total: count ?? 0,
46-
page: q.page,
47-
pageSize: PAGE_SIZE,
48-
});
49-
});
34+
// asyncRoute: an `async` handler that rejects is invisible to Express 4, so a
35+
// throw from queryEvents (a network blip against PostgREST, say) would hang the
36+
// request instead of answering. Forwarding to next() lets app.ts answer 500.
37+
auditRouter.get(
38+
"/",
39+
asyncRoute(async (req, res) => {
40+
const userId = res.locals.userId as string;
41+
const email = res.locals.userEmail as string | undefined;
42+
const db = createServerSupabase();
43+
const parsed = parseQuery(req.query as Record<string, unknown>, PAGE_SIZE);
44+
if (!parsed.ok) return void res.status(400).json({ detail: parsed.error });
45+
const q = parsed.query;
46+
const { data, error, count } = await queryEvents(db, userId, email, q);
47+
if (error) return void sendInternalError(res, error);
48+
res.json({
49+
events: data ?? [],
50+
total: count ?? 0,
51+
page: q.page,
52+
pageSize: PAGE_SIZE,
53+
});
54+
}),
55+
);
5056

5157
// Synchronous CSV export. Still here for curl users and older clients; the
5258
// frontend goes through the durable "audit-csv" export job instead. Both
5359
// emit the same bytes because both render through buildAuditCsv.
54-
auditRouter.get("/export", requireMfaIfEnrolled, async (req, res) => {
55-
const userId = res.locals.userId as string;
56-
const email = res.locals.userEmail as string | undefined;
57-
const db = createServerSupabase();
58-
const parsed = parseQuery(
59-
req.query as Record<string, unknown>,
60-
AUDIT_EXPORT_LIMIT,
61-
);
62-
if (!parsed.ok) return void res.status(400).json({ detail: parsed.error });
63-
let csv: string;
64-
try {
65-
csv = await buildAuditCsv(db, userId, email, parsed.query);
66-
} catch (err) {
67-
// buildAuditCsv throws so the async job retries; here the throw becomes
68-
// the same generic 500 this route has always sent, never the raw DB
69-
// message. Unwrap `cause` so the log still carries the PostgrestError's
70-
// code/details/hint rather than only its message.
71-
return void sendInternalError(
72-
res,
73-
err instanceof Error && err.cause ? err.cause : err,
60+
auditRouter.get(
61+
"/export",
62+
requireMfaIfEnrolled,
63+
asyncRoute(async (req, res) => {
64+
const userId = res.locals.userId as string;
65+
const email = res.locals.userEmail as string | undefined;
66+
const db = createServerSupabase();
67+
const parsed = parseQuery(
68+
req.query as Record<string, unknown>,
69+
AUDIT_EXPORT_LIMIT,
7470
);
75-
}
76-
res.setHeader("Content-Type", "text/csv; charset=utf-8");
77-
res.setHeader(
78-
"Content-Disposition",
79-
`attachment; filename="${AUDIT_CSV_FILENAME}"`,
80-
);
81-
res.send(csv);
82-
});
71+
if (!parsed.ok) return void res.status(400).json({ detail: parsed.error });
72+
let csv: string;
73+
try {
74+
csv = await buildAuditCsv(db, userId, email, parsed.query);
75+
} catch (err) {
76+
// buildAuditCsv throws so the async job retries; here the throw becomes
77+
// the same generic 500 this route has always sent, never the raw DB
78+
// message. Unwrap `cause` so the log still carries the PostgrestError's
79+
// code/details/hint rather than only its message.
80+
return void sendInternalError(
81+
res,
82+
err instanceof Error && err.cause ? err.cause : err,
83+
);
84+
}
85+
res.setHeader("Content-Type", "text/csv; charset=utf-8");
86+
res.setHeader(
87+
"Content-Disposition",
88+
`attachment; filename="${AUDIT_CSV_FILENAME}"`,
89+
);
90+
res.send(csv);
91+
}),
92+
);

0 commit comments

Comments
 (0)