diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5f5b8e1..7935d0a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -52,8 +52,8 @@ model Tip { fromAddress String /// Amount in USDC stroops (stored as String to preserve i128 precision) amount String - /// Optional supporter message - message String @default("") + /// Optional supporter message (bounded to the contract's 280-byte limit) + message String @default("") @db.VarChar(280) creatorId String creator Creator @relation(fields: [creatorId], references: [id]) createdAt DateTime @default(now()) diff --git a/src/modules/analytics/analytics.routes.ts b/src/modules/analytics/analytics.routes.ts index 716b875..0a37004 100644 --- a/src/modules/analytics/analytics.routes.ts +++ b/src/modules/analytics/analytics.routes.ts @@ -37,8 +37,11 @@ export const analyticsRoutes: FastifyPluginAsync = async (app) => { app.get("/timeseries", async (request, reply) => { const { user } = request; const query = request.query as Record; - const days = DaysQuery.parse(query["days"]); - const series = await getTimeSeries(user.sub, days); + const parsed = DaysQuery.safeParse(query["days"]); + if (!parsed.success) { + return reply.status(400).send({ error: parsed.error.flatten() }); + } + const series = await getTimeSeries(user.sub, parsed.data); return reply.send({ series }); }); @@ -46,8 +49,11 @@ export const analyticsRoutes: FastifyPluginAsync = async (app) => { app.get("/top-supporters", async (request, reply) => { const { user } = request; const query = request.query as Record; - const limit = LimitQuery.parse(query["limit"]); - const supporters = await getTopSupporters(user.sub, limit); + const parsed = LimitQuery.safeParse(query["limit"]); + if (!parsed.success) { + return reply.status(400).send({ error: parsed.error.flatten() }); + } + const supporters = await getTopSupporters(user.sub, parsed.data); return reply.send({ supporters }); }); @@ -55,8 +61,11 @@ export const analyticsRoutes: FastifyPluginAsync = async (app) => { app.get("/recent", async (request, reply) => { const { user } = request; const query = request.query as Record; - const limit = LimitQuery.parse(query["limit"] ?? "20"); - const tips = await getRecentTips(user.sub, limit); + const parsed = LimitQuery.safeParse(query["limit"] ?? "20"); + if (!parsed.success) { + return reply.status(400).send({ error: parsed.error.flatten() }); + } + const tips = await getRecentTips(user.sub, parsed.data); return reply.send({ tips }); }); }; diff --git a/src/modules/analytics/analytics.service.ts b/src/modules/analytics/analytics.service.ts index b69d15e..25c41b6 100644 --- a/src/modules/analytics/analytics.service.ts +++ b/src/modules/analytics/analytics.service.ts @@ -200,7 +200,11 @@ export async function getTopSupporters( * Default: last 20. */ export async function getRecentTips(creatorId: string, limit = 20) { - return db.tip.findMany({ + const key = `analytics:recent:${creatorId}:${limit}`; + const cached = await cacheGet>>(key); + if (cached) return cached; + + const result = await db.tip.findMany({ where: { creatorId }, orderBy: { ledgerAt: "desc" }, take: limit, @@ -213,4 +217,8 @@ export async function getRecentTips(creatorId: string, limit = 20) { ledgerAt: true, }, }); + + // Short TTL keeps the live feed responsive; the dashboard polls every 15 s. + await cacheSet(key, result, CACHE_TTL); + return result; } diff --git a/src/modules/auth/auth.routes.ts b/src/modules/auth/auth.routes.ts index 986108f..5ebf497 100644 --- a/src/modules/auth/auth.routes.ts +++ b/src/modules/auth/auth.routes.ts @@ -10,6 +10,52 @@ import type { FastifyPluginAsync } from "fastify"; import { z } from "zod"; import { generateChallenge, verifyChallenge } from "./auth.service.js"; +/** + * POST /auth/challenge generates a 32-byte nonce and writes it to Redis. + * It is unauthenticated by nature, so under the shared global budget (100 + * req/min) a single caller can exhaust the allowance minting nonces for + * arbitrary wallet addresses, filling Redis with short-lived keys and + * starving legitimate auth traffic. + * + * This dedicated limiter enforces a much tighter per-IP cap. The limit is + * configurable via AUTH_CHALLENGE_RATE_LIMIT (default 5 req/min per IP). + */ +const AUTH_CHALLENGE_RATE_LIMIT = Number(process.env.AUTH_CHALLENGE_RATE_LIMIT) || 5; + +interface SlidingWindow { + count: number; + windowStart: number; +} + +function buildSlidingWindowLimiter( + limit: number, + windowMs: number = 60_000, +): (request: any, reply: any) => void { + const clients = new Map(); + + return (request: any, reply: any) => { + const ip = request.ip; + const now = Date.now(); + let entry = clients.get(ip); + + if (!entry || now - entry.windowStart >= windowMs) { + entry = { count: 1, windowStart: now }; + clients.set(ip, entry); + return; + } + + entry.count += 1; + if (entry.count > limit) { + reply.status(429).send({ + error: { + code: "AUTH_RATE_LIMITED", + message: "Too many auth requests. Try again in a minute.", + }, + }); + } + }; +} + const ChallengeBody = z.object({ walletAddress: z.string().min(56).max(56), }); @@ -21,7 +67,14 @@ const VerifyBody = z.object({ export const authRoutes: FastifyPluginAsync = async (app) => { // ── POST /challenge ──────────────────────────────────────────────────────── - app.post("/challenge", async (request, reply) => { + const challengeLimiter = buildSlidingWindowLimiter(AUTH_CHALLENGE_RATE_LIMIT); + app.addHook("preHandler", challengeLimiter, async (request, reply) => { + if (request.method !== "POST" || !request.url.startsWith("/challenge")) { + return; + } + }); + + app.post("/challenge", { onRequest: [challengeLimiter] }, async (request, reply) => { const body = ChallengeBody.safeParse(request.body); if (!body.success) { return reply.status(400).send({ error: body.error.flatten() }); diff --git a/src/modules/creator/creator.service.ts b/src/modules/creator/creator.service.ts index 3b920ef..e167bf8 100644 --- a/src/modules/creator/creator.service.ts +++ b/src/modules/creator/creator.service.ts @@ -109,6 +109,17 @@ export async function claimSlug(input: ClaimSlugInput) { ); } + // jarId must match the slug — the on-chain jar is addressed by slug. + // A mismatch would make the indexer unable to resolve tips to the creator's + // public page, or silently drop them. + const expectedJarId = `@${input.slug}`; + if (input.jarId !== expectedJarId) { + throw Object.assign( + new Error(`jarId must be "${expectedJarId}" to match the claimed slug.`), + { statusCode: 400 }, + ); + } + // Check availability. This pre-check handles the common case, but two // requests can race and both pass it before either writes — the unique // constraint below is what actually prevents a duplicate. diff --git a/src/modules/qr/qr.routes.ts b/src/modules/qr/qr.routes.ts index 52aee60..d563fc2 100644 --- a/src/modules/qr/qr.routes.ts +++ b/src/modules/qr/qr.routes.ts @@ -12,7 +12,54 @@ import QRCode from "qrcode"; import { config } from "../../config.js"; import { getCreatorBySlug } from "../creator/creator.service.js"; +/** + * QR generation is CPU-heavy (512 px PNG rasterisation) and the routes are + * public. The global 100 req/min budget is far too permissive here — a single + * client can exhaust it with QR requests alone, leaving no capacity for the + * rest of the API. + * + * This dedicated limiter enforces a tighter 10 req/min per IP, configurable + * via the QR_RATE_LIMIT env variable (default 10). + */ +const QR_RATE_LIMIT = Number(process.env.QR_RATE_LIMIT) || 10; + +interface SlidingWindow { + count: number; + windowStart: number; +} + +function buildQrRateLimiter( + limit: number, + windowMs: number = 60_000, +): (request: any, reply: any) => void { + const clients = new Map(); + + return (request: any, reply: any) => { + const ip = request.ip; + const now = Date.now(); + let entry = clients.get(ip); + + if (!entry || now - entry.windowStart >= windowMs) { + entry = { count: 1, windowStart: now }; + clients.set(ip, entry); + return; + } + + entry.count += 1; + if (entry.count > limit) { + reply.status(429).send({ + error: { + code: "QR_RATE_LIMITED", + message: "Too many QR requests. Try again in a minute.", + }, + }); + } + }; +} + export const qrRoutes: FastifyPluginAsync = async (app) => { + const qrLimiter = buildQrRateLimiter(QR_RATE_LIMIT); + app.addHook("preHandler", qrLimiter); // ── GET /:slug — SVG ─────────────────────────────────────────────────────── app.get("/:slug", async (request, reply) => { diff --git a/src/modules/webhooks/webhooks.routes.ts b/src/modules/webhooks/webhooks.routes.ts index ff4264f..747bd93 100644 --- a/src/modules/webhooks/webhooks.routes.ts +++ b/src/modules/webhooks/webhooks.routes.ts @@ -23,7 +23,18 @@ export const webhookRoutes: FastifyPluginAsync = async (app) => { // ── GET / ────────────────────────────────────────────────────────────────── app.get("/", async (request, reply) => { const { user } = request; - const webhooks = await listWebhooks(user.sub); + const query = z + .object({ + limit: z.string().regex(/^[0-9]+$/).transform(Number).optional(), + offset: z.string().regex(/^[0-9]+$/).transform(Number).optional(), + }) + .safeParse(request.query); + if (!query.success) { + return reply.status(400).send({ error: query.error.flatten() }); + } + const limit = Math.min(query.data.limit ?? 50, 100); + const offset = query.data.offset ?? 0; + const webhooks = await listWebhooks(user.sub, limit, offset); return reply.send({ webhooks }); }); @@ -46,7 +57,10 @@ export const webhookRoutes: FastifyPluginAsync = async (app) => { app.delete("/:id", async (request, reply) => { const { user } = request; const { id } = request.params as { id: string }; - await deleteWebhook(user.sub, id); + const deleted = await deleteWebhook(user.sub, id); + if (!deleted) { + return reply.status(404).send({ error: "Webhook not found" }); + } return reply.status(204).send(); }); }; diff --git a/src/modules/webhooks/webhooks.service.ts b/src/modules/webhooks/webhooks.service.ts index e2db91a..df58cce 100644 --- a/src/modules/webhooks/webhooks.service.ts +++ b/src/modules/webhooks/webhooks.service.ts @@ -17,7 +17,28 @@ import { logger } from "../../utils/logger.js"; const webhookLogger = logger.child({ component: "webhook" }); const TIMEOUT_MS = 5_000; -const MAX_BODY_SIZE = 1_024; // truncate response log to 1 KB +const MAX_BODY_SIZE = 1_024; // truncate response log to 1 KB +const MAX_PAYLOAD_SIZE = 2_048; // bound stored delivery payload to 2 KB + +/** + * Reduce the stored payload to a diagnostic minimum when it exceeds + * MAX_PAYLOAD_SIZE. The full payload is what was sent to the webhook; the + * stored copy only needs to be large enough to tell what was dispatched. + * Fields are trimmed in priority order: message first, then amountRaw. + */ +function boundPayload(payload: WebhookPayload, limit: number): object { + const json = JSON.stringify(payload); + if (Buffer.byteLength(json, "utf8") <= limit) return payload as object; + + // Truncate message first — it is the largest variable field. + const truncated: WebhookPayload = { ...payload, message: payload.message.slice(0, 200) + "…" }; + let reduced = JSON.stringify(truncated); + if (Buffer.byteLength(reduced, "utf8") <= limit) return truncated as object; + + // Still too large — strip amountRaw as well. + const stripped: WebhookPayload = { ...truncated, amountRaw: "" }; + return stripped as object; +} // ── Types ───────────────────────────────────────────────────────────────────── @@ -131,13 +152,22 @@ export async function createWebhook(creatorId: string, url: string, secret: stri return db.webhook.create({ data: { creatorId, url, secret } }); } -export async function listWebhooks(creatorId: string) { +export async function listWebhooks(creatorId: string, limit = 50, offset = 0) { return db.webhook.findMany({ where: { creatorId }, + orderBy: { createdAt: "desc" }, + take: limit, + skip: offset, select: { id: true, url: true, enabled: true, createdAt: true }, }); } -export async function deleteWebhook(creatorId: string, webhookId: string) { - await db.webhook.deleteMany({ where: { id: webhookId, creatorId } }); +export async function deleteWebhook( + creatorId: string, + webhookId: string, +): Promise { + const { count } = await db.webhook.deleteMany({ + where: { id: webhookId, creatorId }, + }); + return count > 0; }