This commit is contained in:
2026-05-27 10:30:23 -04:00
parent 5214412fff
commit 1e1773c186
48 changed files with 5351 additions and 160 deletions

View File

@@ -0,0 +1,12 @@
import type { APIEvent } from "@solidjs/start/server";
import { createTRPCRouter, publicProcedure } from "~/server/api/utils";
// Example of versioned API router
export const apiRouter = createTRPCRouter({
// v1 endpoints
hello: publicProcedure.query(() => {
return { message: "Hello from API v1" };
}),
});
export default apiRouter;

View File

@@ -1,5 +1,6 @@
import { initTRPC, TRPCError } from "@trpc/server";
import type { TRPCContext } from "./trpc";
import { checkRateLimitOrThrow } from "~/server/lib/ratelimit";
const t = initTRPC.context<TRPCContext>().create();
@@ -31,28 +32,15 @@ const isAdmin = t.middleware(({ ctx, next }) => {
export const adminProcedure = t.procedure.use(isAdmin);
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
const isRateLimited = t.middleware(({ ctx, next }) => {
const isRateLimited = t.middleware(async ({ ctx, next, path }) => {
const identifier = ctx.user?.id ?? ctx.apiKey ?? "anonymous";
const now = Date.now();
const entry = rateLimitMap.get(identifier);
const limit = 100;
const windowMs = 60_000;
const tier = ctx.user?.role === "admin" ? "admin" : ctx.user ? "authenticated" : "public";
if (!entry || now > entry.resetAt) {
rateLimitMap.set(identifier, { count: 1, resetAt: now + windowMs });
return next();
}
// Sensitive operations get stricter limits
const sensitivePaths = ["login", "signup", "forgotPassword", "resetPassword"];
const effectiveTier = sensitivePaths.some((p) => path.includes(p)) ? "sensitive" : tier;
if (entry.count >= limit) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "Rate limit exceeded",
});
}
entry.count++;
await checkRateLimitOrThrow(identifier, effectiveTier);
return next();
});

View File

@@ -0,0 +1,51 @@
import { TRPCError } from "@trpc/server";
/**
* Sanitizes string inputs to prevent XSS.
* Escapes HTML entities and strips dangerous attributes.
*/
export function sanitizeHtml(input: string): string {
return input
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#x27;")
.replace(/\//g, "&#x2F;");
}
/**
* Validates that a string doesn't contain HTML or script tags.
* Throws TRPCError if malicious content is detected.
*/
export function validateNoHtml(input: string, fieldName: string): void {
const htmlPattern = /<[^>]*>/;
if (htmlPattern.test(input)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `${fieldName} contains invalid characters`,
});
}
}
/**
* Validates string length with meaningful error messages.
*/
export function validateStringLength(
input: string,
fieldName: string,
options: { min?: number; max?: number },
): void {
if (options.min !== undefined && input.length < options.min) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `${fieldName} must be at least ${options.min} characters`,
});
}
if (options.max !== undefined && input.length > options.max) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `${fieldName} must be at most ${options.max} characters`,
});
}
}