Skip to content

Commit d24468a

Browse files
committed
fix(webapp): catch loader/action throws before Remix serializes them
Two webapp routes left their loader/action bodies uncaught. When the underlying call (Prisma, etc.) threw, Remix's default error path serialized `error.message` into the 500 response body, surfacing implementation detail to API consumers — and via the SDK, to users. This complements the earlier sweep over `catch (e) { return json({error: e.message}, 500) }` shapes; that fix could not reach routes which had no catch in the first place. Each handler now wraps its body in try/catch, re-throws `Response` instances so auth helpers' `throw json(...)` / `throw redirect(...)` pass through unchanged, logs non-Response errors, and returns a generic body. The polling changelogs widget returns `{ changelogs: [] }` 200 instead of a 500 — degrading silently across a transient blip is better UX for a 60s-cadence widget, and the leak risk is identical (neither shape carries the error message). Covers: - apps/webapp/app/routes/api.v1.projects.\$projectRef.envvars.\$slug.\$name.ts (loader + action) - apps/webapp/app/routes/resources.platform-changelogs.tsx (loader)
1 parent 5dacab0 commit d24468a

3 files changed

Lines changed: 141 additions & 107 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Wrap two loaders/actions that previously let thrown errors propagate to Remix's default 500 serializer, which writes `error.message` into the response body. When the underlying call (Prisma, etc.) fails, the raw error string was reaching API consumers — including the SDK, which surfaces it back to users via `TriggerApiError`. Each handler now catches non-Response errors, logs server-side, and returns a generic 500 body. `throw json(...)` / `throw redirect(...)` from auth helpers is re-thrown unchanged.
7+
8+
Covers `api.v1.projects.$projectRef.envvars.$slug.$name.ts` (loader + action) and `resources.platform-changelogs.tsx` (loader).

apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts

Lines changed: 112 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
authenticatedEnvironmentForAuthentication,
88
branchNameFromRequest,
99
} from "~/services/apiAuth.server";
10+
import { logger } from "~/services/logger.server";
1011
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
1112

1213
const ParamsSchema = z.object({
@@ -22,73 +23,82 @@ export async function action({ params, request }: ActionFunctionArgs) {
2223
return json({ error: "Invalid params" }, { status: 400 });
2324
}
2425

25-
const authenticationResult = await authenticateRequest(request);
26+
try {
27+
const authenticationResult = await authenticateRequest(request);
2628

27-
if (!authenticationResult) {
28-
return json({ error: "Invalid or Missing API key" }, { status: 401 });
29-
}
30-
31-
const environment = await authenticatedEnvironmentForAuthentication(
32-
authenticationResult,
33-
parsedParams.data.projectRef,
34-
parsedParams.data.slug,
35-
branchNameFromRequest(request)
36-
);
37-
38-
// Find the environment variable
39-
const variable = await prisma.environmentVariable.findFirst({
40-
where: {
41-
key: parsedParams.data.name,
42-
projectId: environment.project.id,
43-
},
44-
});
45-
46-
if (!variable) {
47-
return json({ error: "Environment variable not found" }, { status: 404 });
48-
}
49-
50-
const repository = new EnvironmentVariablesRepository();
51-
52-
switch (request.method.toUpperCase()) {
53-
case "DELETE": {
54-
const result = await repository.deleteValue(environment.project.id, {
55-
id: variable.id,
56-
environmentId: environment.id,
57-
});
29+
if (!authenticationResult) {
30+
return json({ error: "Invalid or Missing API key" }, { status: 401 });
31+
}
5832

59-
if (result.success) {
60-
return json({ success: true });
61-
} else {
62-
return json({ error: result.error }, { status: 400 });
63-
}
33+
const environment = await authenticatedEnvironmentForAuthentication(
34+
authenticationResult,
35+
parsedParams.data.projectRef,
36+
parsedParams.data.slug,
37+
branchNameFromRequest(request)
38+
);
39+
40+
// Find the environment variable
41+
const variable = await prisma.environmentVariable.findFirst({
42+
where: {
43+
key: parsedParams.data.name,
44+
projectId: environment.project.id,
45+
},
46+
});
47+
48+
if (!variable) {
49+
return json({ error: "Environment variable not found" }, { status: 404 });
6450
}
65-
case "PUT":
66-
case "POST": {
67-
const jsonBody = await request.json();
6851

69-
const body = UpdateEnvironmentVariableRequestBody.safeParse(jsonBody);
52+
const repository = new EnvironmentVariablesRepository();
7053

71-
if (!body.success) {
72-
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
73-
}
54+
switch (request.method.toUpperCase()) {
55+
case "DELETE": {
56+
const result = await repository.deleteValue(environment.project.id, {
57+
id: variable.id,
58+
environmentId: environment.id,
59+
});
7460

75-
const result = await repository.edit(environment.project.id, {
76-
values: [
77-
{
78-
value: body.data.value,
79-
environmentId: environment.id,
80-
},
81-
],
82-
id: variable.id,
83-
keepEmptyValues: true,
84-
});
85-
86-
if (result.success) {
87-
return json({ success: true });
88-
} else {
89-
return json({ error: result.error }, { status: 400 });
61+
if (result.success) {
62+
return json({ success: true });
63+
} else {
64+
return json({ error: result.error }, { status: 400 });
65+
}
66+
}
67+
case "PUT":
68+
case "POST": {
69+
const jsonBody = await request.json();
70+
71+
const body = UpdateEnvironmentVariableRequestBody.safeParse(jsonBody);
72+
73+
if (!body.success) {
74+
return json(
75+
{ error: "Invalid request body", issues: body.error.issues },
76+
{ status: 400 }
77+
);
78+
}
79+
80+
const result = await repository.edit(environment.project.id, {
81+
values: [
82+
{
83+
value: body.data.value,
84+
environmentId: environment.id,
85+
},
86+
],
87+
id: variable.id,
88+
keepEmptyValues: true,
89+
});
90+
91+
if (result.success) {
92+
return json({ success: true });
93+
} else {
94+
return json({ error: result.error }, { status: 400 });
95+
}
9096
}
9197
}
98+
} catch (error) {
99+
if (error instanceof Response) throw error;
100+
logger.error("Failed to update environment variable", { error });
101+
return json({ error: "Internal Server Error" }, { status: 500 });
92102
}
93103
}
94104

@@ -99,48 +109,54 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
99109
return json({ error: "Invalid params" }, { status: 400 });
100110
}
101111

102-
const authenticationResult = await authenticateRequest(request);
112+
try {
113+
const authenticationResult = await authenticateRequest(request);
103114

104-
if (!authenticationResult) {
105-
return json({ error: "Invalid or Missing API key" }, { status: 401 });
106-
}
115+
if (!authenticationResult) {
116+
return json({ error: "Invalid or Missing API key" }, { status: 401 });
117+
}
107118

108-
const environment = await authenticatedEnvironmentForAuthentication(
109-
authenticationResult,
110-
parsedParams.data.projectRef,
111-
parsedParams.data.slug,
112-
branchNameFromRequest(request)
113-
);
114-
115-
// Find the environment variable
116-
const variable = await prisma.environmentVariable.findFirst({
117-
where: {
118-
key: parsedParams.data.name,
119-
projectId: environment.project.id,
120-
},
121-
});
122-
123-
if (!variable) {
124-
return json({ error: "Environment variable not found" }, { status: 404 });
125-
}
119+
const environment = await authenticatedEnvironmentForAuthentication(
120+
authenticationResult,
121+
parsedParams.data.projectRef,
122+
parsedParams.data.slug,
123+
branchNameFromRequest(request)
124+
);
125+
126+
// Find the environment variable
127+
const variable = await prisma.environmentVariable.findFirst({
128+
where: {
129+
key: parsedParams.data.name,
130+
projectId: environment.project.id,
131+
},
132+
});
133+
134+
if (!variable) {
135+
return json({ error: "Environment variable not found" }, { status: 404 });
136+
}
126137

127-
const repository = new EnvironmentVariablesRepository();
138+
const repository = new EnvironmentVariablesRepository();
128139

129-
const variables = await repository.getEnvironmentWithRedactedSecrets(
130-
environment.project.id,
131-
environment.id,
132-
environment.parentEnvironmentId ?? undefined
133-
);
140+
const variables = await repository.getEnvironmentWithRedactedSecrets(
141+
environment.project.id,
142+
environment.id,
143+
environment.parentEnvironmentId ?? undefined
144+
);
134145

135-
const environmentVariable = variables.find((v) => v.key === parsedParams.data.name);
146+
const environmentVariable = variables.find((v) => v.key === parsedParams.data.name);
136147

137-
if (!environmentVariable) {
138-
return json({ error: "Environment variable not found" }, { status: 404 });
139-
}
148+
if (!environmentVariable) {
149+
return json({ error: "Environment variable not found" }, { status: 404 });
150+
}
140151

141-
return json({
142-
name: environmentVariable.key,
143-
value: environmentVariable.value,
144-
isSecret: environmentVariable.isSecret,
145-
});
152+
return json({
153+
name: environmentVariable.key,
154+
value: environmentVariable.value,
155+
isSecret: environmentVariable.isSecret,
156+
});
157+
} catch (error) {
158+
if (error instanceof Response) throw error;
159+
logger.error("Failed to get environment variable", { error });
160+
return json({ error: "Internal Server Error" }, { status: 500 });
161+
}
146162
}

apps/webapp/app/routes/resources.platform-changelogs.tsx

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { json } from "@remix-run/node";
22
import type { LoaderFunctionArgs } from "@remix-run/node";
33
import { useFetcher, type ShouldRevalidateFunction } from "@remix-run/react";
44
import { useEffect, useRef } from "react";
5+
import { logger } from "~/services/logger.server";
56
import { requireUserId } from "~/services/session.server";
67
import { getRecentChangelogs, verifyOrgMembership } from "~/services/platformNotifications.server";
78

@@ -12,20 +13,29 @@ export type PlatformChangelogsLoaderData = {
1213
};
1314

1415
export async function loader({ request }: LoaderFunctionArgs) {
15-
const userId = await requireUserId(request);
16-
const url = new URL(request.url);
17-
const rawOrganizationId = url.searchParams.get("organizationId") ?? undefined;
18-
const rawProjectId = url.searchParams.get("projectId") ?? undefined;
16+
try {
17+
const userId = await requireUserId(request);
18+
const url = new URL(request.url);
19+
const rawOrganizationId = url.searchParams.get("organizationId") ?? undefined;
20+
const rawProjectId = url.searchParams.get("projectId") ?? undefined;
1921

20-
const { organizationId, projectId } = await verifyOrgMembership({
21-
userId,
22-
organizationId: rawOrganizationId,
23-
projectId: rawProjectId,
24-
});
22+
const { organizationId, projectId } = await verifyOrgMembership({
23+
userId,
24+
organizationId: rawOrganizationId,
25+
projectId: rawProjectId,
26+
});
2527

26-
const changelogs = await getRecentChangelogs({ userId, organizationId, projectId });
28+
const changelogs = await getRecentChangelogs({ userId, organizationId, projectId });
2729

28-
return json<PlatformChangelogsLoaderData>({ changelogs });
30+
return json<PlatformChangelogsLoaderData>({ changelogs });
31+
} catch (error) {
32+
if (error instanceof Response) throw error;
33+
logger.error("Failed to load platform changelogs", { error });
34+
// Polling widget — degrade silently so a transient DB blip doesn't paint
35+
// the dashboard with errors every 60s. Empty payload keeps the consumer's
36+
// fetcher.data shape stable; the fault is recorded server-side.
37+
return json<PlatformChangelogsLoaderData>({ changelogs: [] });
38+
}
2939
}
3040

3141
const POLL_INTERVAL_MS = 60_000;

0 commit comments

Comments
 (0)