Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
21 changes: 15 additions & 6 deletions src/modules/analytics/analytics.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,26 +37,35 @@ export const analyticsRoutes: FastifyPluginAsync = async (app) => {
app.get("/timeseries", async (request, reply) => {
const { user } = request;
const query = request.query as Record<string, string>;
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 });
});

// ── GET /top-supporters ────────────────────────────────────────────────────
app.get("/top-supporters", async (request, reply) => {
const { user } = request;
const query = request.query as Record<string, string>;
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 });
});

// ── GET /recent ────────────────────────────────────────────────────────────
app.get("/recent", async (request, reply) => {
const { user } = request;
const query = request.query as Record<string, string>;
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 });
});
};
10 changes: 9 additions & 1 deletion src/modules/analytics/analytics.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Awaited<ReturnType<typeof db.tip.findMany>>>(key);
if (cached) return cached;

const result = await db.tip.findMany({
where: { creatorId },
orderBy: { ledgerAt: "desc" },
take: limit,
Expand All @@ -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;
}
55 changes: 54 additions & 1 deletion src/modules/auth/auth.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, SlidingWindow>();

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),
});
Expand All @@ -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() });
Expand Down
11 changes: 11 additions & 0 deletions src/modules/creator/creator.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
47 changes: 47 additions & 0 deletions src/modules/qr/qr.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, SlidingWindow>();

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) => {
Expand Down
18 changes: 16 additions & 2 deletions src/modules/webhooks/webhooks.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});

Expand All @@ -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();
});
};
38 changes: 34 additions & 4 deletions src/modules/webhooks/webhooks.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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<boolean> {
const { count } = await db.webhook.deleteMany({
where: { id: webhookId, creatorId },
});
return count > 0;
}