diff --git a/src/components/admin/status-card-grid.tsx b/src/components/admin/status-card-grid.tsx
index edc819b15..47094c43c 100644
--- a/src/components/admin/status-card-grid.tsx
+++ b/src/components/admin/status-card-grid.tsx
@@ -157,7 +157,7 @@ export function buildCards(data: StatusOverview): StatusCardProps[] {
{ label: "Push", value: data.integrations.webPush },
],
href: "/admin#section-admin-umami",
- cta: "Configure",
+ cta: "Open integrations",
},
{
title: "Monitoring",
@@ -187,7 +187,7 @@ export function buildCards(data: StatusOverview): StatusCardProps[] {
{ label: "Retain", value: `${data.backups.retentionDays}d` },
],
href: "/admin#section-system-status",
- cta: "View backups",
+ cta: "Open system status",
},
{
title: "Maintenance",
diff --git a/src/components/settings/ai-section.tsx b/src/components/settings/ai-section.tsx
index fafb8727d..e830c235a 100644
--- a/src/components/settings/ai-section.tsx
+++ b/src/components/settings/ai-section.tsx
@@ -362,7 +362,7 @@ function InsightsSettingsCard({
{settings?.privacyMode === "raw" && (
-
+
{t("settings.rawDataWarning")}
)}
diff --git a/src/lib/__tests__/idempotency.test.ts b/src/lib/__tests__/idempotency.test.ts
index 866e8762b..4d73e6eb0 100644
--- a/src/lib/__tests__/idempotency.test.ts
+++ b/src/lib/__tests__/idempotency.test.ts
@@ -300,6 +300,24 @@ describe("withIdempotency body-content exclusion (P12)", () => {
expect(prisma.idempotencyKey.create).not.toHaveBeenCalled();
});
+ it.each([
+ ["task-id substring", '{"error":"task-id must not contain spaces"}'],
+ ["risk-management word", '{"data":{"note":"risk-management"}}'],
+ ["disk-io metric", '{"data":{"metric":"disk-io"}}'],
+ ])(
+ "still caches a benign payload whose text contains %s (no false positive)",
+ async (_label, body) => {
+ const handler = vi.fn(
+ async () => new NextResponse(body, { status: 422 }),
+ );
+ const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
+ await wrapped(
+ makeRequest("POST", { "idempotency-key": "key-12345678" }),
+ );
+ expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1);
+ },
+ );
+
it("still caches a normal payload that does not carry a secret", async () => {
const handler = vi.fn(
async () =>
diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts
index 8c6131675..f9c3a8852 100644
--- a/src/lib/idempotency.ts
+++ b/src/lib/idempotency.ts
@@ -224,14 +224,15 @@ export function withIdempotency<
// with, but if a future caller forgets we refuse to leak.
// `hlk_` = our access tokens
// `hlr_` = our refresh tokens
- // `sk-` = OpenAI keys (also matches `sk-ant-` Anthropic keys)
+ // `sk-…` = OpenAI / Anthropic keys (full token form, not the
+ // raw substring — a 422 body explaining "task-id…"
+ // or any other word containing `sk-` would otherwise
+ // silently break idempotency for benign retries).
+ const SECRET_PATTERN =
+ /(?:\b(?:hlk_|hlr_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/;
const cloned = response.clone();
const text = await cloned.text();
- if (
- !text.includes("hlk_") &&
- !text.includes("hlr_") &&
- !text.includes("sk-")
- ) {
+ if (!SECRET_PATTERN.test(text)) {
await persistCached(ctx, response, text);
}
}