Skip to content

Commit 8173cf9

Browse files
committed
feat(web/api): wire write+delete scopes for REST and MCP
Add the four daily-write REST endpoints and five matching MCP tools backed by PAT bearer auth, plus two new scopes (submission:delete, comment:delete) for author-only deletes. Cores extracted into lib/ so the web action, REST route, and MCP tool share one implementation per noun. Endpoints: POST /api/v1/comments comment:write POST /api/v1/votes vote:write POST /api/v1/saves save:write DELETE /api/v1/submissions/{id} submission:delete (author-only) DELETE /api/v1/comments/{id} comment:delete (author-only) MCP tools: post_comment, vote, save, delete_submission, delete_comment. Cores: lib/comments.ts createComment, deleteCommentAsAuthor lib/votes.ts castVote, setSave lib/submissions.ts adds deleteSubmissionAsAuthor Audit-driven correctness fixes folded into the comment core: - deleteCommentAsAuthor wraps the reply-check + delete in a tx with FOR UPDATE on the target. createComment locks the parent FOR SHARE before insert. parent_id has no FK; without serialization a hard delete + concurrent reply could leave an orphaned parent_id. - Locked accounts now return reason=locked instead of silently routing to the moderation queue, matching lib/submissions.ts and lib/votes.ts. - Replies must target an approved, non-deleted parent. The web UI hid reply links for non-visible parents; PAT/MCP could bypass that. - Notifications gate on initialState=approved so a "reply" alert cannot point at a tombstone the recipient cannot read yet. - CommentThread renders id="comment-<id>" so the #comment-<id> fragment that REST/MCP and the notifications page emit resolves. MCP duplication trimmed: formatZodIssues + enforceRateLimit helpers keep wording stable via a singular RATE_LIMIT_NOUN map.
1 parent b598462 commit 8173cf9

14 files changed

Lines changed: 1187 additions & 305 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* DELETE /api/v1/comments/[id] — author-only delete via PAT.
3+
*
4+
* Mirrors deleteComment (web UI server action). Soft-deletes when
5+
* replies exist (tombstone preserved so the thread doesn't reshape);
6+
* hard-deletes the row otherwise.
7+
*
8+
* Author check happens inside deleteCommentAsAuthor; PAT scope is
9+
* enforced here. Charged against the `comments` daily bucket.
10+
*/
11+
12+
import { authenticate, requireScope } from "@/lib/api/auth";
13+
import { checkAndIncrement } from "@/lib/api/rate-limit";
14+
import { forbidden, notFound, rateLimited } from "@/lib/api/errors";
15+
import { ok, preflight, problemResponse } from "@/lib/api/response";
16+
import { deleteCommentAsAuthor } from "@/lib/comments";
17+
18+
const UUID_RE =
19+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
20+
21+
export async function OPTIONS(): Promise<Response> {
22+
return preflight();
23+
}
24+
25+
export async function DELETE(
26+
req: Request,
27+
{ params }: { params: Promise<{ id: string }> },
28+
): Promise<Response> {
29+
const { id } = await params;
30+
if (!UUID_RE.test(id)) return problemResponse(notFound("Invalid id."));
31+
32+
const auth = await authenticate(req);
33+
if (!auth.ok) return problemResponse(auth.problem);
34+
35+
const denied = requireScope(auth.token, "comment:delete");
36+
if (denied) return problemResponse(denied.problem);
37+
38+
const limit = await checkAndIncrement(auth.token.id, "comments");
39+
if (!limit.ok) {
40+
return problemResponse(
41+
rateLimited(
42+
`Daily comment-write limit (${limit.limit}) exceeded for this token.`,
43+
limit.resetAt,
44+
),
45+
);
46+
}
47+
48+
const result = await deleteCommentAsAuthor(auth.user.id, id);
49+
if (!result.ok) {
50+
if (result.reason === "forbidden") {
51+
return problemResponse(
52+
forbidden("You can only delete your own comments."),
53+
);
54+
}
55+
return problemResponse(notFound("Comment not found."));
56+
}
57+
58+
return ok({ id, deleted: true, submissionId: result.submissionId });
59+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
* POST /api/v1/comments — post a comment or reply via PAT.
3+
*
4+
* Mirrors submitComment (web UI server action) but takes the author
5+
* identity from a Bearer token instead of a cookie session. Replies
6+
* are created by passing parentId; the parent must belong to the same
7+
* submissionId.
8+
*/
9+
10+
import { authenticate, requireScope } from "@/lib/api/auth";
11+
import { checkAndIncrement } from "@/lib/api/rate-limit";
12+
import {
13+
forbidden,
14+
notFound,
15+
rateLimited,
16+
validation,
17+
} from "@/lib/api/errors";
18+
import { created, preflight, problemResponse } from "@/lib/api/response";
19+
import { commentInputSchema, createComment } from "@/lib/comments";
20+
21+
export async function OPTIONS(): Promise<Response> {
22+
return preflight();
23+
}
24+
25+
export async function POST(req: Request): Promise<Response> {
26+
const auth = await authenticate(req);
27+
if (!auth.ok) return problemResponse(auth.problem);
28+
29+
const denied = requireScope(auth.token, "comment:write");
30+
if (denied) return problemResponse(denied.problem);
31+
32+
// Parse + validate before bumping the rate-limit bucket — see the
33+
// submissions route for the rationale.
34+
let body: unknown;
35+
try {
36+
body = await req.json();
37+
} catch {
38+
return problemResponse(validation("Request body must be valid JSON."));
39+
}
40+
41+
const parsed = commentInputSchema.safeParse(body);
42+
if (!parsed.success) {
43+
return problemResponse(
44+
validation(
45+
"Comment validation failed.",
46+
parsed.error.issues.map((i) => ({
47+
field: i.path.join("."),
48+
message: i.message,
49+
})),
50+
),
51+
);
52+
}
53+
54+
const limit = await checkAndIncrement(auth.token.id, "comments");
55+
if (!limit.ok) {
56+
return problemResponse(
57+
rateLimited(
58+
`Daily comment limit (${limit.limit}) exceeded for this token.`,
59+
limit.resetAt,
60+
),
61+
);
62+
}
63+
64+
const result = await createComment(auth.user.id, parsed.data);
65+
66+
if (!result.ok) {
67+
if (result.reason === "not_found") {
68+
return problemResponse(
69+
notFound("Submission or parent comment not found."),
70+
);
71+
}
72+
if (result.reason === "locked") {
73+
// The "locked" outcome covers both an account lock (the user's
74+
// role is "locked") and a submission lock (the thread is closed
75+
// to new comments). Keep the message neutral so it covers both.
76+
return problemResponse(
77+
forbidden(
78+
"Your account is locked, or this submission is closed to new comments.",
79+
),
80+
);
81+
}
82+
return problemResponse(validation("Comment failed."));
83+
}
84+
85+
return created(
86+
{
87+
id: result.commentId,
88+
pending: result.pending,
89+
url: `https://claudepot.com/post/${parsed.data.submissionId}#comment-${result.commentId}`,
90+
},
91+
`https://claudepot.com/post/${parsed.data.submissionId}#comment-${result.commentId}`,
92+
);
93+
}

web/src/app/api/v1/saves/route.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* POST /api/v1/saves — toggle a private bookmark via PAT.
3+
*
4+
* Body: { submissionId: uuid, saved: boolean }
5+
*
6+
* saved = true → insert (idempotent — duplicate inserts are absorbed)
7+
* saved = false → delete (idempotent — missing rows are absorbed)
8+
*
9+
* Bookmarks are private to the token's owning user; nothing in this
10+
* surface is publicly observable.
11+
*/
12+
13+
import { authenticate, requireScope } from "@/lib/api/auth";
14+
import { checkAndIncrement } from "@/lib/api/rate-limit";
15+
import { notFound, rateLimited, validation } from "@/lib/api/errors";
16+
import { ok, preflight, problemResponse } from "@/lib/api/response";
17+
import { saveInputSchema, setSave } from "@/lib/votes";
18+
19+
export async function OPTIONS(): Promise<Response> {
20+
return preflight();
21+
}
22+
23+
export async function POST(req: Request): Promise<Response> {
24+
const auth = await authenticate(req);
25+
if (!auth.ok) return problemResponse(auth.problem);
26+
27+
const denied = requireScope(auth.token, "save:write");
28+
if (denied) return problemResponse(denied.problem);
29+
30+
let body: unknown;
31+
try {
32+
body = await req.json();
33+
} catch {
34+
return problemResponse(validation("Request body must be valid JSON."));
35+
}
36+
37+
const parsed = saveInputSchema.safeParse(body);
38+
if (!parsed.success) {
39+
return problemResponse(
40+
validation(
41+
"Save validation failed.",
42+
parsed.error.issues.map((i) => ({
43+
field: i.path.join("."),
44+
message: i.message,
45+
})),
46+
),
47+
);
48+
}
49+
50+
const limit = await checkAndIncrement(auth.token.id, "saves");
51+
if (!limit.ok) {
52+
return problemResponse(
53+
rateLimited(
54+
`Daily save limit (${limit.limit}) exceeded for this token.`,
55+
limit.resetAt,
56+
),
57+
);
58+
}
59+
60+
const result = await setSave(auth.user.id, parsed.data);
61+
62+
if (!result.ok) {
63+
return problemResponse(
64+
notFound("Submission not found, or not in a saveable state."),
65+
);
66+
}
67+
68+
return ok({ submissionId: parsed.data.submissionId, saved: result.saved });
69+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* DELETE /api/v1/submissions/[id] — author-only soft delete via PAT.
3+
*
4+
* Mirrors deleteSubmission (web UI server action). The row stays in
5+
* the DB with `deleted_at` set; reads filter it out everywhere except
6+
* the staff queue.
7+
*
8+
* Author check happens inside deleteSubmissionAsAuthor; PAT scope is
9+
* enforced here. We charge against the `submissions` daily bucket so
10+
* a leaked token can't mass-delete past the daily limit.
11+
*/
12+
13+
import { authenticate, requireScope } from "@/lib/api/auth";
14+
import { checkAndIncrement } from "@/lib/api/rate-limit";
15+
import { forbidden, notFound, rateLimited } from "@/lib/api/errors";
16+
import { ok, preflight, problemResponse } from "@/lib/api/response";
17+
import { deleteSubmissionAsAuthor } from "@/lib/submissions";
18+
19+
const UUID_RE =
20+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
21+
22+
export async function OPTIONS(): Promise<Response> {
23+
return preflight();
24+
}
25+
26+
export async function DELETE(
27+
req: Request,
28+
{ params }: { params: Promise<{ id: string }> },
29+
): Promise<Response> {
30+
const { id } = await params;
31+
if (!UUID_RE.test(id)) return problemResponse(notFound("Invalid id."));
32+
33+
const auth = await authenticate(req);
34+
if (!auth.ok) return problemResponse(auth.problem);
35+
36+
const denied = requireScope(auth.token, "submission:delete");
37+
if (denied) return problemResponse(denied.problem);
38+
39+
const limit = await checkAndIncrement(auth.token.id, "submissions");
40+
if (!limit.ok) {
41+
return problemResponse(
42+
rateLimited(
43+
`Daily submission-write limit (${limit.limit}) exceeded for this token.`,
44+
limit.resetAt,
45+
),
46+
);
47+
}
48+
49+
const result = await deleteSubmissionAsAuthor(auth.user.id, id);
50+
if (!result.ok) {
51+
if (result.reason === "forbidden") {
52+
return problemResponse(
53+
forbidden("You can only delete your own submissions."),
54+
);
55+
}
56+
return problemResponse(notFound("Submission not found."));
57+
}
58+
59+
return ok({ id, deleted: true });
60+
}

web/src/app/api/v1/votes/route.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* POST /api/v1/votes — cast / change / clear a vote via PAT.
3+
*
4+
* Body: { submissionId: uuid, value: 1 | -1 | 0 }
5+
*
6+
* value = 1 → upvote
7+
* value = -1 → downvote (gated on karma >= 100, like the web UI)
8+
* value = 0 → clear the existing vote
9+
*
10+
* The DB trigger handles score deltas; the action upserts on
11+
* (user_id, submission_id) so flipping is one row, not two.
12+
*/
13+
14+
import { authenticate, requireScope } from "@/lib/api/auth";
15+
import { checkAndIncrement } from "@/lib/api/rate-limit";
16+
import {
17+
forbidden,
18+
notFound,
19+
rateLimited,
20+
validation,
21+
} from "@/lib/api/errors";
22+
import { ok, preflight, problemResponse } from "@/lib/api/response";
23+
import { castVote, voteInputSchema } from "@/lib/votes";
24+
25+
export async function OPTIONS(): Promise<Response> {
26+
return preflight();
27+
}
28+
29+
export async function POST(req: Request): Promise<Response> {
30+
const auth = await authenticate(req);
31+
if (!auth.ok) return problemResponse(auth.problem);
32+
33+
const denied = requireScope(auth.token, "vote:write");
34+
if (denied) return problemResponse(denied.problem);
35+
36+
let body: unknown;
37+
try {
38+
body = await req.json();
39+
} catch {
40+
return problemResponse(validation("Request body must be valid JSON."));
41+
}
42+
43+
const parsed = voteInputSchema.safeParse(body);
44+
if (!parsed.success) {
45+
return problemResponse(
46+
validation(
47+
"Vote validation failed.",
48+
parsed.error.issues.map((i) => ({
49+
field: i.path.join("."),
50+
message: i.message,
51+
})),
52+
),
53+
);
54+
}
55+
56+
const limit = await checkAndIncrement(auth.token.id, "votes");
57+
if (!limit.ok) {
58+
return problemResponse(
59+
rateLimited(
60+
`Daily vote limit (${limit.limit}) exceeded for this token.`,
61+
limit.resetAt,
62+
),
63+
);
64+
}
65+
66+
const result = await castVote(auth.user.id, parsed.data);
67+
68+
if (!result.ok) {
69+
if (result.reason === "missing_user") {
70+
// Token references a deleted user — already handled in
71+
// authenticate(), but the core may still detect this if the user
72+
// was deleted between auth and the vote. Surface as 401.
73+
return problemResponse({
74+
type: "https://claudepot.com/api/errors/unauthorized",
75+
title: "Unauthorized",
76+
status: 401,
77+
detail: "Token references a deleted user.",
78+
});
79+
}
80+
if (result.reason === "locked") {
81+
return problemResponse(forbidden("Account is locked."));
82+
}
83+
if (result.reason === "karma_gate") {
84+
return problemResponse(
85+
forbidden(
86+
"Downvotes require at least 100 karma. Your account is below the threshold.",
87+
),
88+
);
89+
}
90+
return problemResponse(
91+
notFound("Submission not found, or not in a votable state."),
92+
);
93+
}
94+
95+
return ok({ submissionId: parsed.data.submissionId, value: result.value });
96+
}

0 commit comments

Comments
 (0)