fix(notifications): keep a quick answer single-use after its entry is purged and document the topic exposure

This commit is contained in:
Marc-André Bombeck committed 2026-10-11 13:46:12 +02:00
1 parent 4ff9aa7e39
commit 5dbc6fcec1
5 files changed
+183 -15

No files matched your search

@@ -10,7 +10,10 @@ import { NextRequest } from "next/server";
const TEST_HMAC_KEY = "test-hmac-key-at-least-32-chars-long-xxxxx";
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn() } },
prisma: {
user: { findUnique: vi.fn() },
idempotencyKey: { create: vi.fn(), deleteMany: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
@@ -67,6 +70,10 @@ beforeEach(() => {
timezone: "Europe/Berlin",
} as never);
vi.mocked(isModuleEnabled).mockResolvedValue(true);
vi.mocked(prisma.idempotencyKey.create).mockResolvedValue({} as never);
vi.mocked(prisma.idempotencyKey.deleteMany).mockResolvedValue({
count: 1,
} as never);
vi.mocked(logTelegramMood).mockResolvedValue({
created: true,
moodEntryId: "m1",
@@ -151,4 +158,42 @@ describe("POST /api/notifications/action/{token}", () => {
const res = await call(token({ action: "mood-bad" }));
expect(res.status).toBe(409);
});
it("refuses a spent nonce with 409 before writing anything", async () => {
const { Prisma } = await import("@/generated/prisma/client");
vi.mocked(prisma.idempotencyKey.create).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("unique", {
code: "P2002",
clientVersion: "test",
}),
);
const res = await call(token());
expect(res.status).toBe(409);
expect(logTelegramMood).not.toHaveBeenCalled();
});
it("claims the nonce until the token expires", async () => {
const now = Date.now();
await call(token({ now }));
expect(prisma.idempotencyKey.create).toHaveBeenCalledWith({
data: expect.objectContaining({
userId: "user-1",
key: "notification-action:nonce-1",
requestFingerprint: null,
expiresAt: new Date(now + ACTION_TOKEN_TTL_MS),
}),
});
});
it("gives the claim back when the write fails for another reason", async () => {
vi.mocked(logTelegramMood).mockRejectedValue(new Error("db down"));
const res = await call(token());
expect(res.status).toBe(500);
expect(prisma.idempotencyKey.deleteMany).toHaveBeenCalledWith({
where: expect.objectContaining({
userId: "user-1",
key: "notification-action:nonce-1",
}),
});
});
});
@@ -13,10 +13,12 @@
* 2. Verify the signature (constant-time) and the expiry.
* 3. Re-check what may have changed since the reminder went out: the
* account still exists and its mood module is still on.
* 4. Write the mood entry under the reminder's nonce. The unique
* `(userId, source, externalId)` key makes the first answer the only
* 4. Claim the reminder's nonce (`action-nonce.ts`), then write the mood
* entry under it. The claim and the entry's unique
* `(userId, source, externalId)` key each make the first answer the only
* one: a repeat tap, or a different answer on the same reminder, is
* refused with 409 and writes nothing.
* refused with 409 and writes nothing. The claim is what still holds
* after the entry was deleted and purged inside the token's lifetime.
*
* The token travels in the path, so it is scrubbed from every log line by
* the `PATH_SECRET_PATHS` entry for this prefix, and the route sits on the
@@ -31,6 +33,10 @@ import { prisma } from "@/lib/db";
import { annotate, getEvent } from "@/lib/logging/context";
import { isModuleEnabled } from "@/lib/modules/gate";
import { logTelegramMood } from "@/lib/mood/create-from-telegram";
import {
claimActionNonce,
releaseActionNonce,
} from "@/lib/notifications/action-nonce";
import {
MOOD_QUICK_ACTIONS,
quickActionExternalId,
@@ -116,6 +122,23 @@ export const POST = apiHandler(
});
}
const alreadyUsed = () => {
annotate({
action: { name: "notification.action.refused" },
meta: { reason: "already_used" },
});
return apiError("This reminder was already answered", 409, {
errorCode: "notification.action.already_used",
});
};
const claimed = await claimActionNonce({
userId: user.id,
nonce: verdict.nonce,
expiry: verdict.expiry,
});
if (!claimed) return alreadyUsed();
const score = MOOD_QUICK_ACTIONS[verdict.action];
let created: boolean;
try {
@@ -129,19 +152,15 @@ export const POST = apiHandler(
created = result.created;
} catch (err) {
// Two taps raced past the existence check; the unique key let one in.
if (!isUniqueViolation(err)) throw err;
if (!isUniqueViolation(err)) {
// Nothing was written: give the answer back so a retry can land.
await releaseActionNonce({ userId: user.id, nonce: verdict.nonce });
throw err;
}
created = false;
}
if (!created) {
annotate({
action: { name: "notification.action.refused" },
meta: { reason: "already_used" },
});
return apiError("This reminder was already answered", 409, {
errorCode: "notification.action.already_used",
});
}
if (!created) return alreadyUsed();
annotate({
action: { name: "notification.action.mood_logged" },
+71
View File
@@ -0,0 +1,71 @@
/**
* The record that a quick-action nonce was spent.
*
* A reminder's answers are single-use through the mood entry they write: its
* unique `(userId, source, externalId)` key refuses a second entry under the
* same nonce. That holds only while the entry exists. A user who answers,
* then deletes the entry and purges it from the trash inside the token's
* six hours, frees the key, and the same notification button would write a
* second entry. So the nonce is claimed on its own as well, in the
* idempotency table, which already holds one-shot claims under a unique key
* and is swept by expiry (`idempotency-cleanup.ts`). The claim expires with
* the token, so the table holds it exactly as long as it matters.
*
* The row can never be read back as a cached response: its method is not an
* HTTP verb, its path names no route, and its fingerprint is null, which
* `withIdempotency` never replays.
*/
import { prisma } from "@/lib/db";
import { isP2002 } from "@/lib/prisma-errors";
import { quickActionExternalId } from "@/lib/notifications/action-token";
const CLAIM_METHOD = "NOTIFICATION_ACTION";
const CLAIM_PATH = "/api/notifications/action";
const CLAIM_STATUS = 0;
/**
* Claim a reminder's nonce. `true` when this call won it, `false` when the
* nonce was already spent (by an earlier answer or a concurrent one).
*/
export async function claimActionNonce(input: {
userId: string;
nonce: string;
expiry: number;
}): Promise<boolean> {
try {
await prisma.idempotencyKey.create({
data: {
userId: input.userId,
key: quickActionExternalId(input.nonce),
method: CLAIM_METHOD,
path: CLAIM_PATH,
requestFingerprint: null,
responseStatus: CLAIM_STATUS,
responseBody: "",
expiresAt: new Date(input.expiry),
},
});
return true;
} catch (err) {
if (isP2002(err)) return false;
throw err;
}
}
/**
* Give a claim back when the write it guarded failed for a reason other than
* the nonce being spent, so the person can tap the answer again.
*/
export async function releaseActionNonce(input: {
userId: string;
nonce: string;
}): Promise<void> {
await prisma.idempotencyKey.deleteMany({
where: {
userId: input.userId,
key: quickActionExternalId(input.nonce),
method: CLAIM_METHOD,
path: CLAIM_PATH,
},
});
}