fix(api): narrow swallowed database errors and redact the incident URL

The incident forwarder stripped the query string but not path-segment
secrets from the reported URL; route it through the same redaction the
message and stack already use. Several delete-and-swallow paths caught
every error as the expected unique/absent-row case, masking real
failures on security-notification, logout, and integration-disconnect
paths and the Telegram reply-consume guard; narrow each to the specific
Prisma code and let real failures surface.
This commit is contained in:
Marc-André Bombeck committed 2026-07-01 23:45:08 +02:00
1 parent 9aa0a3597a
commit 336ce5aef3
10 files changed
+136 -22

No files matched your search

+8 -1
View File
@@ -1,5 +1,6 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { isP2025 } from "@/lib/prisma-errors";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
@@ -71,9 +72,15 @@ export const DELETE = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "fitbit.credentials.delete" } });
// A missing connection row (P2025) is a benign "already disconnected"
// no-op; any other failure must propagate rather than be swallowed —
// otherwise the encrypted OAuth tokens can be left orphaned while the user
// is told the integration was disconnected.
await prisma.fitbitConnection
.delete({ where: { userId: user.id } })
.catch(() => {});
.catch((err) => {
if (!isP2025(err)) throw err;
});
await prisma.user.update({
where: { id: user.id },
+14 -5
View File
@@ -1,5 +1,6 @@
import { timingSafeEqual } from "node:crypto";
import { prisma } from "@/lib/db";
import { isP2025 } from "@/lib/prisma-errors";
import { decrypt } from "@/lib/crypto";
import {
answerTelegramCallbackQuery,
@@ -945,11 +946,19 @@ async function handleTextMessage(update: TelegramUpdate) {
context.userId === user.id &&
(context.kind === "mood_note" || context.kind === "measure_value")
) {
// Consume the context row regardless of outcome so a reply can't be
// replayed onto the entry twice.
await prisma.telegramPromptContext
.delete({ where: { id: context.id } })
.catch(() => {});
// The delete IS the replay guard: consuming the context row is what
// stops a redelivered reply from being applied to the entry twice. Make
// it authoritative — if the delete fails for any reason OTHER than the
// row already being gone (P2025), do NOT apply the reply; abort and let
// Telegram redeliver against a still-present row. P2025 means the row
// was already consumed, which is the guard succeeding, so proceed.
try {
await prisma.telegramPromptContext.delete({
where: { id: context.id },
});
} catch (err) {
if (!isP2025(err)) return;
}
const userMsgId = message?.message_id;
if (context.expiresAt.getTime() < Date.now()) {
const resp = await sendTelegramMessage(
+8 -1
View File
@@ -1,5 +1,6 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { isP2025 } from "@/lib/prisma-errors";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
@@ -69,9 +70,15 @@ export const DELETE = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "whoop.credentials.delete" } });
// A missing connection row (P2025) is a benign "already disconnected"
// no-op; any other failure must propagate rather than be swallowed —
// otherwise the encrypted OAuth tokens can be left orphaned while the user
// is told the integration was disconnected.
await prisma.whoopConnection
.delete({ where: { userId: user.id } })
.catch(() => {});
.catch((err) => {
if (!isP2025(err)) throw err;
});
await prisma.user.update({
where: { id: user.id },
+8 -2
View File
@@ -1,6 +1,7 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { isP2025 } from "@/lib/prisma-errors";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { withingsCredentialsSchema } from "@/lib/validations/withings";
@@ -64,10 +65,15 @@ export const DELETE = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "withings.credentials.delete" } });
// Remove connection first
// Remove connection first. A missing connection row (P2025) is a benign
// "already disconnected" no-op; any other failure must propagate rather
// than be swallowed — otherwise the encrypted OAuth tokens can be left
// orphaned while the user is told the integration was disconnected.
await prisma.withingsConnection
.delete({ where: { userId: user.id } })
.catch(() => {});
.catch((err) => {
if (!isP2025(err)) throw err;
});
// Remove credentials
await prisma.user.update({