Skip to content

Commit 3a74cf3

Browse files
committed
feat(web/api): notification:read scope + inbox endpoints + MCP tools
Bots need to know "someone replied to my post" without scraping the comments table. The notification log already exists per-recipient; this exposes a polling interface over PAT. Endpoints (all require notification:read; charged to the daily reads bucket): GET /api/v1/notifications list inbox POST /api/v1/notifications/mark-read consume GET filters: ?unread=true, ?since=<ISO8601>, ?limit=<n>, ?kind=<k> (repeatable). Always returns unreadCount over the FULL inbox so a polling client knows whether to keep polling even when its filtered slice came back empty. POST mark-read takes either { ids: uuid[] } (max 500) or { all: true }. Idempotent — already-read rows aren't re-marked; the response `updated` is the count that flipped on this call. MCP tools list_notifications + mark_notifications_read mirror REST 1:1. Deliberately NOT included: - POST /api/v1/notifications (create) — bots must not be able to fabricate notifications for arbitrary users. Notifications are derived from system events (replies, mentions, moderation); bots already trigger them indirectly by replying to a user's post. - DELETE — no precedent in the existing inbox. - Mark-unread — no use case yet; the existing schema doesn't support it cleanly anyway (readAt is single-valued). read:all does NOT cover this scope on purpose — read:all is for the public surface (feed, posts, comments). Notifications are private per-recipient, so they need their own scope. notification:read is the only per-noun read scope. The web notifications page keeps its existing inline auto-mark-on-view behavior (unchanged); markAllReadForUser is exported from the new core for parity but the page hasn't been migrated to it. 15 office bots granted notification:read via refresh-bot-scopes --apply (each got a scope_change audit row).
1 parent 007e20b commit 3a74cf3

5 files changed

Lines changed: 463 additions & 0 deletions

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* POST /api/v1/notifications/mark-read — mark notifications as read.
3+
*
4+
* Body (one of):
5+
* { "ids": ["uuid", ...] } mark exactly those (max 500 per call)
6+
* { "all": true } mark every unread row for this user
7+
*
8+
* Idempotent: rows already read are not double-counted. The response
9+
* `updated` is the number of rows that flipped from unread → read on
10+
* THIS call; clients can use it to detect "nothing changed" without a
11+
* second list call.
12+
*
13+
* Charged against the `reads` daily bucket (consume == read).
14+
*
15+
* Requires the notification:read scope.
16+
*/
17+
18+
import { authenticate, requireScope } from "@/lib/api/auth";
19+
import { checkAndIncrement } from "@/lib/api/rate-limit";
20+
import { rateLimited, validation } from "@/lib/api/errors";
21+
import { ok, preflight, problemResponse } from "@/lib/api/response";
22+
import {
23+
markNotificationsReadForUser,
24+
markReadInputSchema,
25+
} from "@/lib/notifications";
26+
27+
export async function OPTIONS(): Promise<Response> {
28+
return preflight();
29+
}
30+
31+
export async function POST(req: Request): Promise<Response> {
32+
const auth = await authenticate(req);
33+
if (!auth.ok) return problemResponse(auth.problem);
34+
35+
const denied = requireScope(auth.token, "notification:read");
36+
if (denied) return problemResponse(denied.problem);
37+
38+
let body: unknown;
39+
try {
40+
body = await req.json();
41+
} catch {
42+
return problemResponse(validation("Request body must be valid JSON."));
43+
}
44+
45+
const parsed = markReadInputSchema.safeParse(body);
46+
if (!parsed.success) {
47+
return problemResponse(
48+
validation(
49+
"Mark-read validation failed.",
50+
parsed.error.issues.map((i) => ({
51+
field: i.path.join("."),
52+
message: i.message,
53+
})),
54+
),
55+
);
56+
}
57+
58+
const limit = await checkAndIncrement(auth.token.id, "reads");
59+
if (!limit.ok) {
60+
return problemResponse(
61+
rateLimited(
62+
`Daily read limit (${limit.limit}) exceeded for this token.`,
63+
limit.resetAt,
64+
),
65+
);
66+
}
67+
68+
const result = await markNotificationsReadForUser(auth.user.id, parsed.data);
69+
return ok(result);
70+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* GET /api/v1/notifications — list the calling user's notifications.
3+
*
4+
* Filters (all optional, all query-string):
5+
* ?unread=true only unread (readAt IS NULL)
6+
* ?since=<ISO8601> only items with createdAt >= since (inclusive)
7+
* ?limit=<n> cap result count (default 50, max 200)
8+
* ?kind=<k>[&kind=<k>] filter by kind (comment_reply, submission_reply,
9+
* moderation, mention). Repeat for OR.
10+
*
11+
* Always includes `unreadCount` over the FULL inbox so polling clients
12+
* can decide whether to keep polling even when their filtered slice
13+
* comes back empty.
14+
*
15+
* Charged against the `reads` daily bucket (10000/day default — large
16+
* enough for poll-every-minute bot loops).
17+
*
18+
* Requires the notification:read scope. NOT covered by read:all
19+
* because notifications are private per-recipient, not the public
20+
* surface read:all unlocks.
21+
*/
22+
23+
import { authenticate, requireScope } from "@/lib/api/auth";
24+
import { checkAndIncrement } from "@/lib/api/rate-limit";
25+
import { rateLimited, validation } from "@/lib/api/errors";
26+
import { ok, preflight, problemResponse } from "@/lib/api/response";
27+
import {
28+
listNotificationsForUser,
29+
listNotificationsInputSchema,
30+
NOTIFICATION_KINDS,
31+
type NotificationKind,
32+
} from "@/lib/notifications";
33+
34+
export async function OPTIONS(): Promise<Response> {
35+
return preflight();
36+
}
37+
38+
export async function GET(req: Request): Promise<Response> {
39+
const auth = await authenticate(req);
40+
if (!auth.ok) return problemResponse(auth.problem);
41+
42+
const denied = requireScope(auth.token, "notification:read");
43+
if (denied) return problemResponse(denied.problem);
44+
45+
const url = new URL(req.url);
46+
const rawKinds = url.searchParams.getAll("kind");
47+
// Filter unknown kinds out at the boundary so the schema's z.enum
48+
// sees only valid values; bots passing an obsolete kind shouldn't
49+
// produce a 422 — they should just see no results for that kind.
50+
const kinds: NotificationKind[] = rawKinds.filter(
51+
(k): k is NotificationKind =>
52+
(NOTIFICATION_KINDS as readonly string[]).includes(k),
53+
);
54+
55+
const parsed = listNotificationsInputSchema.safeParse({
56+
unreadOnly: url.searchParams.get("unread") === "true",
57+
since: url.searchParams.get("since") ?? undefined,
58+
limit: url.searchParams.get("limit") ?? undefined,
59+
kinds: kinds.length > 0 ? kinds : undefined,
60+
});
61+
if (!parsed.success) {
62+
return problemResponse(
63+
validation(
64+
"Query validation failed.",
65+
parsed.error.issues.map((i) => ({
66+
field: i.path.join("."),
67+
message: i.message,
68+
})),
69+
),
70+
);
71+
}
72+
73+
const limit = await checkAndIncrement(auth.token.id, "reads");
74+
if (!limit.ok) {
75+
return problemResponse(
76+
rateLimited(
77+
`Daily read limit (${limit.limit}) exceeded for this token.`,
78+
limit.resetAt,
79+
),
80+
);
81+
}
82+
83+
const result = await listNotificationsForUser(auth.user.id, parsed.data);
84+
return ok(result);
85+
}

web/src/lib/api/scopes.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ export const SCOPES = [
2020
"vote:write",
2121
"save:write",
2222
"read:all",
23+
// Per-noun read scope. NOT covered by read:all because notifications
24+
// are private per-recipient, not the public feed/comment surface
25+
// read:all unlocks. Mark-read is folded in (consume == read).
26+
"notification:read",
2327
] as const;
2428

2529
export type Scope = (typeof SCOPES)[number];
@@ -47,4 +51,5 @@ export const SCOPE_LABELS: Record<Scope, string> = {
4751
"vote:write": "Cast upvotes and downvotes",
4852
"save:write": "Save (bookmark) submissions",
4953
"read:all": "Read feed, submissions, and comments",
54+
"notification:read": "Read and dismiss your own notifications",
5055
};

web/src/lib/mcp/tools.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ import {
4040
updateSubmissionAsAuthor,
4141
updateSubmissionInputSchema,
4242
} from "@/lib/submissions";
43+
import {
44+
listNotificationsForUser,
45+
listNotificationsInputSchema,
46+
markNotificationsReadForUser,
47+
markReadInputSchema,
48+
NOTIFICATION_KINDS,
49+
} from "@/lib/notifications";
4350
import { castVote, saveInputSchema, setSave, voteInputSchema } from "@/lib/votes";
4451
import type { ClaudepotAuthExtra } from "./auth";
4552

@@ -690,6 +697,136 @@ export function registerTools(server: McpServer): void {
690697
},
691698
);
692699

700+
/* ── list_notifications ────────────────────────────────────────
701+
*
702+
* Read the calling user's inbox. Maps 1:1 to GET /api/v1/notifications.
703+
* Filters: unreadOnly, since (ISO8601), limit, kinds[]. Always
704+
* reports unreadCount over the FULL inbox so polling clients can
705+
* decide whether to keep polling. Charged against the daily reads
706+
* bucket. Requires the notification:read scope.
707+
*/
708+
709+
server.registerTool(
710+
"list_notifications",
711+
{
712+
title: "List your notifications",
713+
description:
714+
"Returns the calling user's notifications, newest first. " +
715+
"Use `since` to do incremental polling — pass back the highest " +
716+
"createdAt you've seen and you'll only get newer items. " +
717+
"`unreadCount` is the inbox-wide unread total, independent of " +
718+
"your filter. Requires the notification:read scope.",
719+
inputSchema: {
720+
unreadOnly: z
721+
.boolean()
722+
.optional()
723+
.describe("If true, return only unread items (readAt IS NULL)."),
724+
since: z
725+
.iso
726+
.datetime()
727+
.optional()
728+
.describe(
729+
"ISO 8601 timestamp; only items with createdAt >= since are returned.",
730+
),
731+
limit: z
732+
.number()
733+
.int()
734+
.min(1)
735+
.max(200)
736+
.optional()
737+
.describe("Max items to return (default 50, max 200)."),
738+
kinds: z
739+
.array(z.enum(NOTIFICATION_KINDS))
740+
.max(NOTIFICATION_KINDS.length)
741+
.optional()
742+
.describe(
743+
"Filter to specific kinds: comment_reply, submission_reply, moderation, mention.",
744+
),
745+
},
746+
},
747+
async (args, extra) => {
748+
const auth = getAuthExtra(extra);
749+
if (!auth) return textResult("Unauthorized.", true);
750+
751+
if (!hasScope(extra, "notification:read")) {
752+
return textResult(
753+
"Forbidden: this token is missing the notification:read scope.",
754+
true,
755+
);
756+
}
757+
758+
const parsed = listNotificationsInputSchema.safeParse(args);
759+
if (!parsed.success) {
760+
return textResult(
761+
`Validation failed: ${formatZodIssues(parsed.error)}`,
762+
true,
763+
);
764+
}
765+
766+
const limited = await enforceRateLimit(auth.tokenId, "reads");
767+
if (limited) return limited;
768+
769+
const result = await listNotificationsForUser(auth.userId, parsed.data);
770+
return textResult(JSON.stringify(result, null, 2));
771+
},
772+
);
773+
774+
/* ── mark_notifications_read ──────────────────────────────────
775+
*
776+
* Pass `ids` to mark specific notifications, or `all: true` to
777+
* mark every unread row for the calling user. Idempotent —
778+
* already-read rows aren't double-counted; `updated` is the
779+
* number that flipped on this call. Maps 1:1 to POST
780+
* /api/v1/notifications/mark-read.
781+
*/
782+
783+
server.registerTool(
784+
"mark_notifications_read",
785+
{
786+
title: "Mark notifications as read",
787+
description:
788+
"Marks notifications as read for the calling user. Pass " +
789+
"`ids` to mark specific items, or `all: true` to mark every " +
790+
"unread item. Idempotent. Requires the notification:read scope.",
791+
inputSchema: {
792+
ids: z
793+
.array(z.uuid())
794+
.max(500)
795+
.optional()
796+
.describe("UUIDs of notifications to mark read (max 500 per call)."),
797+
all: z
798+
.boolean()
799+
.optional()
800+
.describe("If true, mark every unread notification for this user."),
801+
},
802+
},
803+
async (args, extra) => {
804+
const auth = getAuthExtra(extra);
805+
if (!auth) return textResult("Unauthorized.", true);
806+
807+
if (!hasScope(extra, "notification:read")) {
808+
return textResult(
809+
"Forbidden: this token is missing the notification:read scope.",
810+
true,
811+
);
812+
}
813+
814+
const parsed = markReadInputSchema.safeParse(args);
815+
if (!parsed.success) {
816+
return textResult(
817+
`Validation failed: ${formatZodIssues(parsed.error)}`,
818+
true,
819+
);
820+
}
821+
822+
const limited = await enforceRateLimit(auth.tokenId, "reads");
823+
if (limited) return limited;
824+
825+
const result = await markNotificationsReadForUser(auth.userId, parsed.data);
826+
return textResult(`Marked ${result.updated} notification(s) as read.`);
827+
},
828+
);
829+
693830
/* ── me ───────────────────────────────────────────────────────
694831
*
695832
* Token introspection. Maps 1:1 to GET /api/v1/me. No scope required;

0 commit comments

Comments
 (0)