diff --git a/app/api/notifications/route.ts b/app/api/notifications/route.ts index fb02805c..ff935d77 100644 --- a/app/api/notifications/route.ts +++ b/app/api/notifications/route.ts @@ -8,12 +8,12 @@ import Notification from "@/models/Notification" import User from "@/models/User" import { logAuditEvent } from "@/lib/security/audit-log" import { buildRateLimitKey, consumeRateLimit, getClientIpAddress, rateLimitExceededResponse } from "@/lib/security/rate-limit" -import { ACTIVITY_CATEGORIES, inferActivityCategory } from "@/lib/activity" -import { createActivity } from "@/lib/services/activity.service" +import { decodeCursor, encodeCursor } from "@/lib/api/cursor" const querySchema = z.object({ userId: z.string().trim().regex(/^[a-f\d]{24}$/i, "Invalid userId.").optional(), limit: z.coerce.number().int().min(1).max(100).default(50), + cursor: z.string().max(1000).optional(), }) const bodySchema = z.object({ @@ -115,11 +115,23 @@ export async function GET(request: Request) { ? query.data.userId : authContext.user._id.toString() - const notifications = await Notification.find({ userId: targetUserId }) - .sort({ timestamp: -1 }) - .limit(query.data.limit) - - const response = NextResponse.json({ success: true, notifications }) + const scope = `notifications:${targetUserId}` + let cursor + try { cursor = decodeCursor(query.data.cursor, scope) } + catch { return NextResponse.json({ error: "Invalid or expired cursor" }, { status: 400 }) } + const notificationFilter: Record = { userId: targetUserId } + if (cursor) notificationFilter.$or = [{ timestamp: { $lt: cursor.timestamp } }, { timestamp: cursor.timestamp, _id: { $lt: cursor.id } }] + const notifications = await Notification.find(notificationFilter) + .select("title message type priority link read timestamp") + .sort({ timestamp: -1, _id: -1 }) + .limit(query.data.limit + 1).maxTimeMS(1000).lean() + + const hasMore = notifications.length > query.data.limit + const page = notifications.slice(0, query.data.limit) + const last = page.at(-1) + const nextCursor = hasMore && last ? encodeCursor({ timestamp: last.timestamp, id: String(last._id) }, scope) : null + + const response = NextResponse.json({ success: true, notifications: page, nextCursor }) return finalizeAuthenticatedResponse(response, authContext) } catch (error) { console.error("NOTIFICATIONS_GET_ERROR", error) diff --git a/app/api/transactions/route.ts b/app/api/transactions/route.ts index d3799bfc..3a9fdb03 100644 --- a/app/api/transactions/route.ts +++ b/app/api/transactions/route.ts @@ -5,6 +5,7 @@ import { finalizeAuthenticatedResponse, normalizeUserRole, requireAuthenticatedU import { parseSearchParams } from "@/lib/api/validation" import dbConnect from "@/lib/dbConnect" import Transaction from "@/models/Transaction" +import { decodeCursor, encodeCursor } from "@/lib/api/cursor" const TRANSACTION_TYPES = [ "investment", @@ -38,6 +39,7 @@ const querySchema = z.object({ }, z.array(z.enum(TRANSACTION_TYPES)).max(10)) .default([]), limit: z.coerce.number().int().min(1).max(200).default(100), + cursor: z.string().max(1000).optional(), }) export async function GET(request: Request) { @@ -68,11 +70,25 @@ export async function GET(request: Request) { filter.type = { $in: query.data.includeTypes } } + const scope = JSON.stringify({ userId: filter.userId || "all", userType: filter.userType || "all", types: query.data.includeTypes }) + let cursor + try { cursor = decodeCursor(query.data.cursor, scope) } + catch { return NextResponse.json({ error: "Invalid or expired cursor" }, { status: 400 }) } + if (cursor) filter.$or = [{ timestamp: { $lt: cursor.timestamp } }, { timestamp: cursor.timestamp, _id: { $lt: cursor.id } }] + const transactions = await Transaction.find(filter) - .sort({ timestamp: -1 }) - .limit(query.data.limit) + .select("userId userType type amount currency description status relatedId timestamp") + .sort({ timestamp: -1, _id: -1 }) + .limit(query.data.limit + 1) + .maxTimeMS(1500) + .lean() + + const hasMore = transactions.length > query.data.limit + const page = transactions.slice(0, query.data.limit) + const last = page.at(-1) + const nextCursor = hasMore && last ? encodeCursor({ timestamp: last.timestamp, id: String(last._id) }, scope) : null - const response = NextResponse.json({ success: true, transactions }) + const response = NextResponse.json({ success: true, transactions: page, nextCursor }) return finalizeAuthenticatedResponse(response, authContext) } catch (error) { console.error("TRANSACTIONS_GET_ERROR", error) diff --git a/docs/query-budgets.md b/docs/query-budgets.md new file mode 100644 index 00000000..0ed9ea96 --- /dev/null +++ b/docs/query-budgets.md @@ -0,0 +1,11 @@ +# Cursor pagination and query budgets + +High-traffic transaction and notification feeds use signed, expiring cursors +bound to the authenticated filter scope. Ordering is `(timestamp DESC, _id +DESC)`, preventing skips or duplicates when rows share timestamps. Tampered, +expired, version-mismatched, or cross-filter cursors are rejected. + +Queries have bounded page sizes, field projections, compound indexes, and +MongoDB execution deadlines. Set `CURSOR_SIGNING_SECRET` independently from +session keys. Before deployment, run `explain("executionStats")` for representative +tenant/type filters and verify the new compound indexes are selected. diff --git a/lib/api/cursor.ts b/lib/api/cursor.ts new file mode 100644 index 00000000..cc68b835 --- /dev/null +++ b/lib/api/cursor.ts @@ -0,0 +1,26 @@ +import { createHmac, timingSafeEqual } from "node:crypto" + +const VERSION = 1 +const TTL_MS = 15 * 60 * 1000 +function secret() { + const value = process.env.CURSOR_SIGNING_SECRET || process.env.JWT_SECRET + if (!value) throw new Error("CURSOR_SIGNING_SECRET is required") + return value +} +function sign(payload: string) { return createHmac("sha256", secret()).update(payload).digest("base64url") } + +export function encodeCursor(position: { timestamp: Date; id: string }, scope: string) { + const payload = Buffer.from(JSON.stringify({ v: VERSION, ts: position.timestamp.toISOString(), id: position.id, scope, exp: Date.now() + TTL_MS })).toString("base64url") + return `${payload}.${sign(payload)}` +} + +export function decodeCursor(value: string | undefined, scope: string) { + if (!value) return null + const [payload, signature] = value.split(".") + if (!payload || !signature) throw new Error("Invalid cursor") + const expected = sign(payload) + if (signature.length !== expected.length || !timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) throw new Error("Invalid cursor signature") + const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) + if (data.v !== VERSION || data.scope !== scope || data.exp < Date.now() || !/^[a-f\d]{24}$/i.test(data.id)) throw new Error("Expired or incompatible cursor") + return { timestamp: new Date(data.ts), id: data.id } +} diff --git a/models/Notification.ts b/models/Notification.ts index 327c2907..9469b520 100644 --- a/models/Notification.ts +++ b/models/Notification.ts @@ -38,5 +38,6 @@ const NotificationSchema: Schema = new Schema({ timestamp: { type: Date, default: Date.now, index: true }, }) -export default (mongoose.models.Notification || - mongoose.model("Notification", NotificationSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>; \ No newline at end of file +NotificationSchema.index({ userId: 1, timestamp: -1, _id: -1 }) + +export default mongoose.models.Notification || mongoose.model("Notification", NotificationSchema) diff --git a/models/Transaction.ts b/models/Transaction.ts index f055a5c7..4f505b78 100644 --- a/models/Transaction.ts +++ b/models/Transaction.ts @@ -30,7 +30,7 @@ export interface ITransaction extends Document { timestamp: Date } -const TransactionSchema: Schema = new Schema({ +const TransactionSchema: Schema = new Schema({ userId: { type: Schema.Types.ObjectId, ref: "User", required: true, index: true }, userType: { type: String, @@ -74,7 +74,10 @@ const TransactionSchema: Schema = new Schema({ relatedId: { type: String }, metadata: { type: Schema.Types.Mixed }, timestamp: { type: Date, default: Date.now }, -}) +}) + +TransactionSchema.index({ userId: 1, userType: 1, timestamp: -1, _id: -1 }) +TransactionSchema.index({ userId: 1, type: 1, timestamp: -1, _id: -1 }) export default (mongoose.models.Transaction || mongoose.model("Transaction", TransactionSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>;