diff --git a/server/src/__tests__/plugin-routes-authz.test.ts b/server/src/__tests__/plugin-routes-authz.test.ts index 963d3cd1f15..0c19848822f 100644 --- a/server/src/__tests__/plugin-routes-authz.test.ts +++ b/server/src/__tests__/plugin-routes-authz.test.ts @@ -42,6 +42,7 @@ async function createApp( jobDeps?: unknown; toolDeps?: unknown; bridgeDeps?: unknown; + captureJsonContext?: (context: unknown, body: unknown) => void; } = {}, ) { const [{ pluginRoutes }, { errorHandler }] = await Promise.all([ @@ -56,6 +57,16 @@ async function createApp( const app = express(); app.use(express.json()); + if (routeOverrides.captureJsonContext) { + app.use((_req, res, next) => { + const originalJson = res.json.bind(res); + res.json = ((body: unknown) => { + routeOverrides.captureJsonContext?.((res as any).__errorContext, body); + return originalJson(body); + }) as typeof res.json; + next(); + }); + } app.use((req, _res, next) => { req.actor = actor as typeof req.actor; next(); @@ -627,6 +638,40 @@ describe.sequential("plugin tool and bridge authz", () => { }); }); + it("attaches worker bridge errors to the HTTP logger context", async () => { + readyPlugin(); + const call = vi.fn().mockRejectedValue(new Error("missing source_objects column")); + const captured: Array<{ context: any; body: unknown }> = []; + const { app } = await createApp(boardActor(), {}, { + bridgeDeps: { + workerManager: { call }, + }, + captureJsonContext: (context, body) => { + captured.push({ context, body }); + }, + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/data/source-objects`) + .send({ companyId: companyA }); + + expect(res.status).toBe(502); + expect(res.body).toMatchObject({ + code: "UNKNOWN", + message: "missing source_objects column", + }); + expect(captured.at(-1)?.context?.error).toMatchObject({ + message: "missing source_objects column", + details: { + pluginId, + pluginKey: "paperclip.example", + bridgeMethod: "getData", + dataKey: "source-objects", + bridgeCode: "UNKNOWN", + }, + }); + }); + it("rejects manual job triggers for non-admin board users", async () => { const scheduler = { triggerJob: vi.fn() }; const jobStore = { getJobByIdForPlugin: vi.fn() }; diff --git a/server/src/routes/plugins.ts b/server/src/routes/plugins.ts index f3b674234b3..6abb7c56ec5 100644 --- a/server/src/routes/plugins.ts +++ b/server/src/routes/plugins.ts @@ -1021,6 +1021,34 @@ export function pluginRoutes( }; } + function attachPluginBridgeErrorContext( + req: Request, + res: Response, + err: unknown, + bridgeError: PluginBridgeErrorResponse, + metadata: Record, + ): void { + const rootError = err instanceof Error ? err : new Error(String(err)); + (res as any).__errorContext = { + error: { + message: bridgeError.message, + stack: rootError.stack, + name: rootError.name, + details: { + ...metadata, + bridgeCode: bridgeError.code, + bridgeDetails: bridgeError.details, + }, + }, + method: req.method, + url: req.originalUrl, + reqBody: req.body, + reqParams: req.params, + reqQuery: req.query, + }; + (res as any).err = rootError; + } + /** * POST /api/plugins/:pluginId/bridge/data * @@ -1072,6 +1100,11 @@ export function pluginRoutes( code: "WORKER_UNAVAILABLE", message: `Plugin is not ready (current status: ${plugin.status})`, }; + attachPluginBridgeErrorContext(req, res, new Error(bridgeError.message), bridgeError, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + bridgeMethod: "getData", + }); res.status(502).json(bridgeError); return; } @@ -1098,6 +1131,12 @@ export function pluginRoutes( res.json({ data: result }); } catch (err) { const bridgeError = mapRpcErrorToBridgeError(err); + attachPluginBridgeErrorContext(req, res, err, bridgeError, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + bridgeMethod: "getData", + dataKey: body.key, + }); res.status(502).json(bridgeError); } }); @@ -1153,6 +1192,11 @@ export function pluginRoutes( code: "WORKER_UNAVAILABLE", message: `Plugin is not ready (current status: ${plugin.status})`, }; + attachPluginBridgeErrorContext(req, res, new Error(bridgeError.message), bridgeError, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + bridgeMethod: "performAction", + }); res.status(502).json(bridgeError); return; } @@ -1179,6 +1223,12 @@ export function pluginRoutes( res.json({ data: result }); } catch (err) { const bridgeError = mapRpcErrorToBridgeError(err); + attachPluginBridgeErrorContext(req, res, err, bridgeError, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + bridgeMethod: "performAction", + actionKey: body.key, + }); res.status(502).json(bridgeError); } }); @@ -1235,6 +1285,12 @@ export function pluginRoutes( code: "WORKER_UNAVAILABLE", message: `Plugin is not ready (current status: ${plugin.status})`, }; + attachPluginBridgeErrorContext(req, res, new Error(bridgeError.message), bridgeError, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + bridgeMethod: "getData", + dataKey: key, + }); res.status(502).json(bridgeError); return; } @@ -1260,6 +1316,12 @@ export function pluginRoutes( res.json({ data: result }); } catch (err) { const bridgeError = mapRpcErrorToBridgeError(err); + attachPluginBridgeErrorContext(req, res, err, bridgeError, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + bridgeMethod: "getData", + dataKey: key, + }); res.status(502).json(bridgeError); } }); @@ -1312,6 +1374,12 @@ export function pluginRoutes( code: "WORKER_UNAVAILABLE", message: `Plugin is not ready (current status: ${plugin.status})`, }; + attachPluginBridgeErrorContext(req, res, new Error(bridgeError.message), bridgeError, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + bridgeMethod: "performAction", + actionKey: key, + }); res.status(502).json(bridgeError); return; } @@ -1337,6 +1405,12 @@ export function pluginRoutes( res.json({ data: result }); } catch (err) { const bridgeError = mapRpcErrorToBridgeError(err); + attachPluginBridgeErrorContext(req, res, err, bridgeError, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + bridgeMethod: "performAction", + actionKey: key, + }); res.status(502).json(bridgeError); } });