Skip to content

Commit c3c773f

Browse files
Bobclaude
andcommitted
harden(comments): fix toggle race, allowlist reactions, document IP-bucket limit
Adversarial self-review follow-ups on the reactions prototype: - toggle() no longer does read-then-write. A concurrent double-toggle from the same voter could both see "no row" and have the second INSERT violate the unique index, surfacing as a 500. Now an idempotent INSERT ... ON CONFLICT DO NOTHING branches on whether a row was written, so it never throws. - handleReactionToggle rejects reactions outside an allowlist (currently just "like", matching the shipped widget) so a caller can't spam arbitrary reaction strings and bloat a comment's count map. - The public reactions route documents that the salted-IP voter hash collapses to a shared "unknown" bucket without a trusted IP (per-visitor dedup is the Tier 2 visitor-identity primitive), mirroring the comment ingest route. Adds handler-level tests (happy path, unsupported reaction, non-approved and missing comment, concurrent-toggle invariant). lint + typecheck clean; 82 comment tests pass. AI disclosure: implemented with assistance from Claude (claude-opus-4-8). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 220b1dd commit c3c773f

4 files changed

Lines changed: 111 additions & 17 deletions

File tree

packages/core/src/api/handlers/comment-reactions.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ import type { ApiResult } from "../types.js";
1919
const REACTION_RATE_LIMIT = 30;
2020
const REACTION_RATE_WINDOW_MINUTES = 10;
2121

22+
/**
23+
* Reactions the system accepts. Positive-only for now (matches the shipped
24+
* widget); kept as an allowlist so a voter can't spam arbitrary reaction
25+
* strings and bloat a comment's count map. Extend (or make configurable) as
26+
* the UI grows.
27+
*/
28+
const ALLOWED_REACTIONS: ReadonlySet<string> = new Set(["like"]);
29+
2230
export interface ReactionToggleResult {
2331
commentId: string;
2432
reaction: string;
@@ -52,6 +60,13 @@ export async function handleReactionToggle(
5260
try {
5361
const { collection, contentId, commentId, reaction, voterHash } = params;
5462

63+
if (!ALLOWED_REACTIONS.has(reaction)) {
64+
return {
65+
success: false,
66+
error: { code: "VALIDATION_ERROR", message: "Unsupported reaction" },
67+
};
68+
}
69+
5570
// The comment must exist, be approved, and belong to this content item.
5671
const comment = await db
5772
.selectFrom("_emdash_comments")

packages/core/src/astro/routes/api/comments/[collection]/[contentId]/reactions.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,12 @@ export const GET: APIRoute = async ({ params, request, locals }) => {
3535
if (dbErr) return dbErr;
3636

3737
try {
38-
// Salted voter hash from request IP (same primitive as comment ip_hash);
39-
// shared "unknown" bucket when no trusted IP is available.
38+
// Salted voter hash from request IP (same primitive as comment ip_hash).
39+
// Behind Cloudflare (CF-Connecting-IP) or a configured trusted proxy this
40+
// is per-visitor. Without a trusted IP it collapses to a shared "unknown"
41+
// bucket, so reaction dedup degrades for those visitors — a real
42+
// per-visitor token is Tier 2 (visitor identity). Operators should set
43+
// trustedProxyHeaders; see the comment ingest route for the same note.
4044
const meta = extractRequestMeta(request, emdash.config);
4145
let voterHash = "unknown";
4246
if (meta.ip) {

packages/core/src/database/repositories/comment-reaction.ts

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -29,20 +29,12 @@ export class CommentReactionRepository {
2929
* if an existing reaction was removed.
3030
*/
3131
async toggle(input: ToggleReactionInput): Promise<{ reacted: boolean }> {
32-
const existing = await this.db
33-
.selectFrom("_emdash_comment_reactions")
34-
.select("id")
35-
.where("comment_id", "=", input.commentId)
36-
.where("voter_hash", "=", input.voterHash)
37-
.where("reaction", "=", input.reaction)
38-
.executeTakeFirst();
39-
40-
if (existing) {
41-
await this.db.deleteFrom("_emdash_comment_reactions").where("id", "=", existing.id).execute();
42-
return { reacted: false };
43-
}
44-
45-
await this.db
32+
// Idempotent add: insert, or no-op if this voter already has this
33+
// reaction. Using ON CONFLICT (rather than read-then-write) avoids a
34+
// TOCTOU race where two concurrent toggles both see "no row" and the
35+
// second INSERT violates the unique index — the old path surfaced that
36+
// as a 500. The unique index doubles as the conflict target.
37+
const inserted = await this.db
4638
.insertInto("_emdash_comment_reactions")
4739
.values({
4840
id: ulid(),
@@ -51,8 +43,21 @@ export class CommentReactionRepository {
5143
voter_hash: input.voterHash,
5244
created_at: new Date().toISOString(),
5345
})
46+
.onConflict((oc) => oc.columns(["comment_id", "voter_hash", "reaction"]).doNothing())
47+
.executeTakeFirst();
48+
49+
if ((inserted.numInsertedOrUpdatedRows ?? 0n) > 0n) {
50+
return { reacted: true };
51+
}
52+
53+
// The reaction already existed → toggle it off.
54+
await this.db
55+
.deleteFrom("_emdash_comment_reactions")
56+
.where("comment_id", "=", input.commentId)
57+
.where("voter_hash", "=", input.voterHash)
58+
.where("reaction", "=", input.reaction)
5459
.execute();
55-
return { reacted: true };
60+
return { reacted: false };
5661
}
5762

5863
/**

packages/core/tests/integration/comments/reactions.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Kysely } from "kysely";
22
import { afterEach, beforeEach, describe, expect, it } from "vitest";
33

4+
import { handleReactionToggle } from "../../../src/api/handlers/comment-reactions.js";
45
import { getCommentsWithDb } from "../../../src/comments/query.js";
56
import { CommentReactionRepository } from "../../../src/database/repositories/comment-reaction.js";
67
import { CommentRepository } from "../../../src/database/repositories/comment.js";
@@ -131,4 +132,73 @@ describe("CommentReactionRepository", () => {
131132
expect(items[0]?.reactions).toEqual({ like: 1 });
132133
});
133134
});
135+
136+
describe("handleReactionToggle", () => {
137+
const base = { collection: "post", contentId: "content-1" };
138+
139+
it("toggles a reaction and returns updated counts", async () => {
140+
const c = await approvedComment();
141+
const r = await handleReactionToggle(db, {
142+
...base,
143+
commentId: c.id,
144+
reaction: "like",
145+
voterHash: "v1",
146+
});
147+
expect(r.success).toBe(true);
148+
if (r.success) {
149+
expect(r.data.reacted).toBe(true);
150+
expect(r.data.counts).toEqual({ like: 1 });
151+
}
152+
});
153+
154+
it("rejects an unsupported reaction with VALIDATION_ERROR", async () => {
155+
const c = await approvedComment();
156+
const r = await handleReactionToggle(db, {
157+
...base,
158+
commentId: c.id,
159+
reaction: "spam",
160+
voterHash: "v1",
161+
});
162+
expect(r.success).toBe(false);
163+
if (!r.success) expect(r.error.code).toBe("VALIDATION_ERROR");
164+
});
165+
166+
it("rejects reacting to a non-approved comment", async () => {
167+
const c = await approvedComment({ status: "pending" });
168+
const r = await handleReactionToggle(db, {
169+
...base,
170+
commentId: c.id,
171+
reaction: "like",
172+
voterHash: "v1",
173+
});
174+
expect(r.success).toBe(false);
175+
if (!r.success) expect(r.error.code).toBe("COMMENT_NOT_APPROVED");
176+
});
177+
178+
it("rejects reacting to a missing comment", async () => {
179+
const r = await handleReactionToggle(db, {
180+
...base,
181+
commentId: "does-not-exist",
182+
reaction: "like",
183+
voterHash: "v1",
184+
});
185+
expect(r.success).toBe(false);
186+
if (!r.success) expect(r.error.code).toBe("NOT_FOUND");
187+
});
188+
189+
it("does not error when two toggles for the same voter race (idempotent add)", async () => {
190+
const c = await approvedComment();
191+
// Fire both concurrently — the old read-then-write path could throw a
192+
// unique-constraint 500 here; ON CONFLICT makes it safe.
193+
const [a, b] = await Promise.all([
194+
handleReactionToggle(db, { ...base, commentId: c.id, reaction: "like", voterHash: "v1" }),
195+
handleReactionToggle(db, { ...base, commentId: c.id, reaction: "like", voterHash: "v1" }),
196+
]);
197+
expect(a.success).toBe(true);
198+
expect(b.success).toBe(true);
199+
// Invariant: never more than one row for the same (comment, voter, reaction).
200+
const counts = await reactions.countsForComments([c.id]);
201+
expect((counts.get(c.id)?.like ?? 0) <= 1).toBe(true);
202+
});
203+
});
134204
});

0 commit comments

Comments
 (0)