security: rate-limit IP-keyed, fail-closed on empty key (#37)

Rate-limiter now accepts string | string[] so callers can key on
multiple buckets simultaneously. If any bucket is exhausted the
request is denied, which lets login/TOTP/reset-password throttle on
BOTH user identifier and source IP without either becoming a bypass.

Fail-closed: empty/whitespace-only keys now deny by default instead
of silently allowing unbounded attempts (was CWE-307 gap).

Degraded-fallback divisor reduced from /10 to /2 — the old aggressive
clamp forced-logged-out legitimate users during brief Redis outages;
/2 still meaningfully slows distributed brute-force.

Callers updated:
- auth.ts (login): both email: and ip: buckets
- auth router requestPasswordReset: email + IP
- auth router resetPassword: IP before lookup, email-reset after
- invite router getInvite/acceptInvite: IP
- user-self-service verifyTotp: userId + IP

TRPCContext now carries clientIp; web tRPC route extracts it from
X-Forwarded-For / X-Real-IP.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 08:19:33 +02:00
parent 534945f6e3
commit 3c5d1d37f7
10 changed files with 290 additions and 106 deletions
+21 -5
View File
@@ -5,6 +5,17 @@ import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import type { NextRequest } from "next/server";
import { auth } from "~/server/auth.js";
function extractClientIp(req: NextRequest): string | null {
const forwarded = req.headers.get("x-forwarded-for");
if (forwarded) {
const first = forwarded.split(",")[0]?.trim();
if (first) return first;
}
const realIp = req.headers.get("x-real-ip");
if (realIp) return realIp.trim();
return null;
}
// Throttle lastActiveAt updates: max once per 60s per user
const lastActiveCache = new Map<string, number>();
const ACTIVITY_THROTTLE_MS = 60_000;
@@ -14,10 +25,14 @@ function trackActivity(userId: string) {
const last = lastActiveCache.get(userId) ?? 0;
if (now - last < ACTIVITY_THROTTLE_MS) return;
lastActiveCache.set(userId, now);
prisma.user.update({
where: { id: userId },
data: { lastActiveAt: new Date(now) },
}).catch(() => {/* ignore */});
prisma.user
.update({
where: { id: userId },
data: { lastActiveAt: new Date(now) },
})
.catch(() => {
/* ignore */
});
}
const handler = async (req: NextRequest) => {
@@ -63,7 +78,8 @@ const handler = async (req: NextRequest) => {
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => createTRPCContext({ session, dbUser, roleDefaults }),
createContext: () =>
createTRPCContext({ session, dbUser, roleDefaults, clientIp: extractClientIp(req) }),
};
if (process.env["NODE_ENV"] === "development") {
+23 -3
View File
@@ -31,6 +31,18 @@ const LoginSchema = z.object({
totp: z.string().max(16).optional(),
});
function extractClientIp(request: Request | undefined): string | null {
if (!request) return null;
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) {
const first = forwarded.split(",")[0]?.trim();
if (first) return first;
}
const realIp = request.headers.get("x-real-ip");
if (realIp) return realIp.trim();
return null;
}
const config = {
...authConfig,
trustHost: true,
@@ -42,17 +54,25 @@ const config = {
password: { label: "Password", type: "password" },
totp: { label: "TOTP", type: "text" },
},
async authorize(credentials) {
async authorize(credentials, request) {
const parsed = LoginSchema.safeParse(credentials);
if (!parsed.success) return null;
const { email, password, totp } = parsed.data;
const isE2eTestMode = process.env["E2E_TEST_MODE"] === "true";
// Rate limit: 5 login attempts per 15 minutes per email
// Rate limit: 5 attempts per 15 min, keyed on BOTH email and
// source IP. Keying on email alone permits per-email lockout DoS
// and lets a single IP brute-force unlimited emails; keying on
// IP alone lets a botnet bypass the limit. Both buckets must be
// within budget for the attempt to proceed (CWE-307).
const ip = extractClientIp(request);
const rateLimitKeys = ip
? [`email:${email.toLowerCase()}`, `ip:${ip}`]
: [`email:${email.toLowerCase()}`];
const rateLimitResult = isE2eTestMode
? { allowed: true }
: await authRateLimiter(email.toLowerCase());
: await authRateLimiter(rateLimitKeys);
if (!rateLimitResult.allowed) {
// Audit failed login (rate limited)
void createAuditEntry({
+38 -3
View File
@@ -103,9 +103,9 @@ describe("rate limiter", () => {
}));
const { createRateLimiter } = await import("../middleware/rate-limit.js");
// Degraded fallback uses max(1, floor(maxRequests/10)), so with
// maxRequests=20 the degraded limit is 2.
const limiter = createRateLimiter(60_000, 20, {
// Degraded fallback uses max(1, floor(maxRequests/2)), so with
// maxRequests=4 the degraded limit is 2 attempts within the window.
const limiter = createRateLimiter(60_000, 4, {
backend: "redis",
redisUrl: "redis://test",
name: "redis-fallback-test",
@@ -120,4 +120,39 @@ describe("rate limiter", () => {
expect(third.allowed).toBe(false);
expect(third.remaining).toBe(0);
});
it("denies by default when called with an empty key (fail-closed)", async () => {
const { createRateLimiter } = await import("../middleware/rate-limit.js");
const limiter = createRateLimiter(60_000, 5, { backend: "memory", name: "empty-key-test" });
const empty = await limiter("");
const whitespace = await limiter(" ");
const emptyArray = await limiter([]);
const allEmpty = await limiter(["", " "]);
expect(empty.allowed).toBe(false);
expect(whitespace.allowed).toBe(false);
expect(emptyArray.allowed).toBe(false);
expect(allEmpty.allowed).toBe(false);
});
it("denies if any key in a multi-key call is over its limit", async () => {
const { createRateLimiter } = await import("../middleware/rate-limit.js");
const limiter = createRateLimiter(60_000, 2, { backend: "memory", name: "multi-key-test" });
// Exhaust the "email:a" bucket alone
await limiter("email:a");
await limiter("email:a");
const emailExhausted = await limiter("email:a");
expect(emailExhausted.allowed).toBe(false);
// A call keyed on both email:a AND ip:x must deny because email:a is
// exhausted, even though ip:x is fresh.
const combined = await limiter(["email:a", "ip:x"]);
expect(combined.allowed).toBe(false);
// A fresh bucket pair still succeeds.
const freshPair = await limiter(["email:b", "ip:y"]);
expect(freshPair.allowed).toBe(true);
});
});
@@ -90,15 +90,16 @@ function makeSelfServiceCtx(dbOverrides: Record<string, unknown> = {}) {
};
}
function makePublicCtx(dbOverrides: Record<string, unknown> = {}) {
function makePublicCtx(overrides: Record<string, unknown> = {}) {
return {
db: {
user: {
findUnique: vi.fn(),
update: vi.fn().mockResolvedValue({}),
...((dbOverrides.user as object | undefined) ?? {}),
...((overrides.user as object | undefined) ?? {}),
},
},
clientIp: (overrides.clientIp as string | null | undefined) ?? null,
};
}
@@ -277,14 +278,27 @@ describe("verifyTotp", () => {
expect(ctx.db.user.findUnique).not.toHaveBeenCalled();
});
it("calls the rate limiter with the userId as key", async () => {
it("calls the rate limiter with both userId and client IP as keys", async () => {
totpValidateMock.mockReturnValue(0);
const ctx = makePublicCtx({
user: { findUnique: vi.fn().mockResolvedValue(mfaUser) },
clientIp: "198.51.100.7",
});
await verifyTotp(ctx as Parameters<typeof verifyTotp>[0], {
userId: "user_1",
token: "123456",
});
expect(totpRateLimiterMock).toHaveBeenCalledWith(["user:user_1", "ip:198.51.100.7"]);
});
it("falls back to userId-only keying when no client IP is available", async () => {
totpValidateMock.mockReturnValue(0);
const ctx = makePublicCtx({ user: { findUnique: vi.fn().mockResolvedValue(mfaUser) } });
await verifyTotp(ctx as Parameters<typeof verifyTotp>[0], {
userId: "user_1",
token: "123456",
});
expect(totpRateLimiterMock).toHaveBeenCalledWith("user_1");
expect(totpRateLimiterMock).toHaveBeenCalledWith(["user:user_1"]);
});
});
+44 -14
View File
@@ -22,7 +22,7 @@ type CreateRateLimiterOptions = {
};
export interface RateLimiter {
(key: string): Promise<RateLimitResult>;
(key: string | readonly string[]): Promise<RateLimitResult>;
reset(): Promise<void>;
}
@@ -212,27 +212,19 @@ export function createRateLimiter(
// When Redis is unavailable, apply a stricter limit to compensate for
// per-node isolation (each process keeps independent in-memory counters,
// so the effective cluster-wide limit is maxRequests × nodeCount).
// so the effective cluster-wide limit is maxRequests × nodeCount). A
// /2 divisor keeps legitimate users out of forced-logout while still
// meaningfully slowing distributed brute-force during Redis outages.
const degradedMemoryBackend = createMemoryBackend(
windowMs,
Math.max(1, Math.floor(maxRequests / 10)),
Math.max(1, Math.floor(maxRequests / 2)),
);
let redisDegraded = false;
const check = (async (key: string) => {
const normalizedKey = key.trim().toLowerCase();
if (!normalizedKey) {
return {
allowed: true,
remaining: maxRequests,
resetAt: new Date(Date.now() + windowMs),
};
}
async function checkOne(normalizedKey: string): Promise<RateLimitResult> {
if (!redisBackend) {
return memoryBackend.check(normalizedKey);
}
try {
const result = await redisBackend.check(normalizedKey);
if (redisDegraded) {
@@ -244,6 +236,44 @@ export function createRateLimiter(
redisDegraded = true;
return degradedMemoryBackend.check(normalizedKey);
}
}
const check = (async (key: string | readonly string[]) => {
const rawKeys = Array.isArray(key) ? key : [key as string];
const normalizedKeys = rawKeys
.map((k) => (typeof k === "string" ? k.trim().toLowerCase() : ""))
.filter((k) => k.length > 0);
// Fail-closed: if every supplied key is empty or whitespace the caller
// has no identity to throttle; deny rather than letting unbounded
// attempts through (CWE-307).
if (normalizedKeys.length === 0) {
logger.warn({ limiter: name }, "Rate limiter called with empty key — denying by default");
return {
allowed: false,
remaining: 0,
resetAt: new Date(Date.now() + windowMs),
};
}
// Check every bucket. If any bucket is exhausted, the request is
// denied; this allows callers to key on both user identifier AND
// request IP without either becoming a bypass.
let denied: RateLimitResult | null = null;
let earliestReset = new Date(Date.now() + windowMs);
let minRemaining = Number.POSITIVE_INFINITY;
for (const normalizedKey of normalizedKeys) {
const result = await checkOne(normalizedKey);
if (!result.allowed && !denied) denied = result;
if (result.resetAt < earliestReset) earliestReset = result.resetAt;
if (result.remaining < minRemaining) minRemaining = result.remaining;
}
if (denied) return denied;
return {
allowed: true,
remaining: minRemaining === Number.POSITIVE_INFINITY ? maxRequests : minRemaining,
resetAt: earliestReset,
};
}) as RateLimiter;
check.reset = async () => {
@@ -1677,6 +1677,7 @@ export function createScopedCallerContext(ctx: ToolContext): TRPCContext {
db: ctx.db,
dbUser: ctx.dbUser,
roleDefaults: ctx.roleDefaults ?? null,
clientIp: null,
};
}
+29 -7
View File
@@ -27,7 +27,11 @@ export const authRouter = createTRPCRouter({
requestPasswordReset: publicProcedure
.input(z.object({ email: z.string().email() }))
.mutation(async ({ ctx, input }) => {
const rl = await authRateLimiter(input.email);
const ipKey = ctx.clientIp ? `ip:${ctx.clientIp}` : "";
const keys = ipKey
? [`email:${input.email.toLowerCase()}`, ipKey]
: [`email:${input.email.toLowerCase()}`];
const rl = await authRateLimiter(keys);
if (!rl.allowed) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
@@ -78,12 +82,19 @@ export const authRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
const rl = await authRateLimiter(input.token);
if (!rl.allowed) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "Too many password reset attempts. Please wait before trying again.",
});
// Rate-limit keyed on IP (token is always new so token-keying is a no-op).
// We cannot key on the resolved email before the token lookup; fall back
// to IP-only here and apply an email-keyed limit AFTER the successful
// lookup to bound per-email brute-force.
const ipKey = ctx.clientIp ? `ip:${ctx.clientIp}` : "";
if (ipKey) {
const rl = await authRateLimiter(ipKey);
if (!rl.allowed) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "Too many password reset attempts. Please wait before trying again.",
});
}
}
const record = await ctx.db.passwordResetToken.findUnique({
@@ -103,6 +114,17 @@ export const authRouter = createTRPCRouter({
throw new TRPCError({ code: "BAD_REQUEST", message: "This reset link has expired." });
}
// Second-layer limit keyed on the resolved email, so a targeted
// attacker cannot exhaust reset attempts for a known user even if
// they cycle source IPs.
const emailRl = await authRateLimiter(`email-reset:${record.email.toLowerCase()}`);
if (!emailRl.allowed) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "Too many password reset attempts. Please wait before trying again.",
});
}
const { hash } = await import("@node-rs/argon2");
const passwordHash = await hash(input.password);
+18 -12
View File
@@ -86,12 +86,15 @@ export const inviteRouter = createTRPCRouter({
getInvite: publicProcedure
.input(z.object({ token: z.string() }))
.query(async ({ ctx, input }) => {
const rl = await authRateLimiter(input.token);
if (!rl.allowed) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "Too many attempts. Please wait before trying again.",
});
const ipKey = ctx.clientIp ? `ip:${ctx.clientIp}` : "";
if (ipKey) {
const rl = await authRateLimiter(ipKey);
if (!rl.allowed) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "Too many attempts. Please wait before trying again.",
});
}
}
const invite = await ctx.db.inviteToken.findUnique({
@@ -115,12 +118,15 @@ export const inviteRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
const rl = await authRateLimiter(input.token);
if (!rl.allowed) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "Too many attempts. Please wait before trying again.",
});
const ipKey = ctx.clientIp ? `ip:${ctx.clientIp}` : "";
if (ipKey) {
const rl = await authRateLimiter(ipKey);
if (!rl.allowed) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "Too many attempts. Please wait before trying again.",
});
}
}
const invite = await ctx.db.inviteToken.findUnique({
@@ -1,8 +1,5 @@
import { Prisma } from "@capakraken/db";
import {
dashboardLayoutSchema,
normalizeDashboardLayout,
} from "@capakraken/shared/schemas";
import { dashboardLayoutSchema, normalizeDashboardLayout } from "@capakraken/shared/schemas";
import type { ColumnPreferences } from "@capakraken/shared/types";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
@@ -20,9 +17,20 @@ export const ToggleFavoriteProjectInputSchema = z.object({
});
export const SetColumnPreferencesInputSchema = z.object({
view: z.enum(["resources", "projects", "allocations", "vacations", "roles", "users", "blueprints"]),
view: z.enum([
"resources",
"projects",
"allocations",
"vacations",
"roles",
"users",
"blueprints",
]),
visible: z.array(z.string()).optional(),
sort: z.object({ field: z.string(), dir: z.enum(["asc", "desc"]) }).nullable().optional(),
sort: z
.object({ field: z.string(), dir: z.enum(["asc", "desc"]) })
.nullable()
.optional(),
rowOrder: z.array(z.string()).nullable().optional(),
});
@@ -36,7 +44,7 @@ export const VerifyTotpInputSchema = z.object({
});
type UserSelfServiceContext = Pick<TRPCContext, "db" | "dbUser" | "session">;
type UserPublicContext = Pick<TRPCContext, "db">;
type UserPublicContext = Pick<TRPCContext, "db" | "clientIp">;
export async function getCurrentUserProfile(ctx: UserSelfServiceContext) {
return findUniqueOrThrow(
@@ -61,9 +69,7 @@ export async function getDashboardLayout(ctx: UserSelfServiceContext) {
select: { dashboardLayout: true, updatedAt: true },
});
const normalized = user?.dashboardLayout
? normalizeDashboardLayout(user.dashboardLayout)
: null;
const normalized = user?.dashboardLayout ? normalizeDashboardLayout(user.dashboardLayout) : null;
return {
layout: normalized?.widgets.length ? normalized : null,
updatedAt: user?.updatedAt ?? null,
@@ -131,7 +137,9 @@ export async function setColumnPreferences(
select: { columnPreferences: true },
});
const prefs = (existing?.columnPreferences ?? {}) as ColumnPreferences;
const prev = (prefs[input.view] as import("@capakraken/shared").ViewPreferences | undefined) ?? { visible: [] };
const prev = (prefs[input.view] as import("@capakraken/shared").ViewPreferences | undefined) ?? {
visible: [],
};
const merged: import("@capakraken/shared").ViewPreferences = {
visible: input.visible ?? prev.visible,
@@ -183,13 +191,30 @@ export async function verifyAndEnableTotp(
const user = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: ctx.dbUser!.id },
select: { id: true, name: true, email: true, totpSecret: true, totpEnabled: true, lastTotpAt: true },
}) as Promise<{ id: string; name: string | null; email: string; totpSecret: string | null; totpEnabled: boolean; lastTotpAt: Date | null } | null>,
select: {
id: true,
name: true,
email: true,
totpSecret: true,
totpEnabled: true,
lastTotpAt: true,
},
}) as Promise<{
id: string;
name: string | null;
email: string;
totpSecret: string | null;
totpEnabled: boolean;
lastTotpAt: Date | null;
} | null>,
"User",
);
if (!user.totpSecret) {
throw new TRPCError({ code: "BAD_REQUEST", message: "No TOTP secret generated. Call generateTotpSecret first." });
throw new TRPCError({
code: "BAD_REQUEST",
message: "No TOTP secret generated. Call generateTotpSecret first.",
});
}
if (user.totpEnabled) {
throw new TRPCError({ code: "BAD_REQUEST", message: "TOTP is already enabled." });
@@ -211,11 +236,11 @@ export async function verifyAndEnableTotp(
}
// Replay-attack prevention: reject if the same 30-second window was already used
if (
user.lastTotpAt != null &&
Date.now() - user.lastTotpAt.getTime() < 30_000
) {
throw new TRPCError({ code: "BAD_REQUEST", message: "TOTP code already used. Wait for the next code." });
if (user.lastTotpAt != null && Date.now() - user.lastTotpAt.getTime() < 30_000) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "TOTP code already used. Wait for the next code.",
});
}
await (ctx.db.user.update as Function)({
@@ -241,16 +266,28 @@ export async function verifyTotp(
ctx: UserPublicContext,
input: z.infer<typeof VerifyTotpInputSchema>,
) {
// Rate limit: max 10 attempts per 30 seconds per userId to prevent brute-force (A01-1)
const rl = await totpRateLimiter(input.userId);
// Rate limit keyed on BOTH userId and source IP. userId-only keying
// permits targeted user-lockout DoS; IP-only permits botnet bypass.
// Both buckets must allow for the attempt to proceed (CWE-307, A01-1).
const ipKey = ctx.clientIp ? `ip:${ctx.clientIp}` : "";
const totpKeys = ipKey ? [`user:${input.userId}`, ipKey] : [`user:${input.userId}`];
const rl = await totpRateLimiter(totpKeys);
if (!rl.allowed) {
throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Too many TOTP attempts. Please wait before trying again." });
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "Too many TOTP attempts. Please wait before trying again.",
});
}
const user = await ctx.db.user.findUnique({
const user = (await ctx.db.user.findUnique({
where: { id: input.userId },
select: { id: true, totpSecret: true, totpEnabled: true, lastTotpAt: true },
}) as { id: string; totpSecret: string | null; totpEnabled: boolean; lastTotpAt: Date | null } | null;
})) as {
id: string;
totpSecret: string | null;
totpEnabled: boolean;
lastTotpAt: Date | null;
} | null;
// Generic error for both not-found and TOTP-not-enabled to prevent user enumeration
if (!user || !user.totpEnabled || !user.totpSecret) {
@@ -273,10 +310,7 @@ export async function verifyTotp(
}
// Replay-attack prevention: reject if the same 30-second window was already used
if (
user.lastTotpAt != null &&
Date.now() - user.lastTotpAt.getTime() < 30_000
) {
if (user.lastTotpAt != null && Date.now() - user.lastTotpAt.getTime() < 30_000) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid TOTP token." });
}
+36 -30
View File
@@ -19,6 +19,8 @@ export interface TRPCContext {
dbUser: { id: string; systemRole: string; permissionOverrides: unknown } | null;
roleDefaults: Record<string, PermissionKey[]> | null;
requestId?: string;
/** Client IP extracted from X-Forwarded-For / X-Real-IP. Null if trust-proxy is off or header absent. */
clientIp: string | null;
}
// Cache role defaults for 60 seconds to avoid DB hit on every request
@@ -53,12 +55,14 @@ export function createTRPCContext(opts: {
session: Session | null;
dbUser?: { id: string; systemRole: string; permissionOverrides: unknown } | null;
roleDefaults?: Record<string, PermissionKey[]> | null;
clientIp?: string | null;
}): TRPCContext {
return {
session: opts.session,
db: prisma,
dbUser: opts.dbUser ?? null,
roleDefaults: opts.roleDefaults ?? null,
clientIp: opts.clientIp ?? null,
};
}
@@ -70,8 +74,7 @@ const t = initTRPC.context<TRPCContext>().create({
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
@@ -147,34 +150,37 @@ if (process.env["E2E_TEST_MODE"] === "true" && process.env["NODE_ENV"] === "prod
* Protected procedure — requires authenticated session AND a valid DB user record.
* This prevents stale sessions from accessing data after the DB user is deleted.
*/
export const protectedProcedure = t.procedure.use(withPrismaErrors).use(withLogging).use(async ({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Authentication required" });
}
if (!ctx.dbUser) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "User account not found" });
}
// Rate limit by user ID
if (!isE2eTestMode) {
const rateLimitResult = await apiRateLimiter(ctx.dbUser.id);
if (!rateLimitResult.allowed) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: `Rate limit exceeded. Try again after ${rateLimitResult.resetAt.toISOString()}`,
});
export const protectedProcedure = t.procedure
.use(withPrismaErrors)
.use(withLogging)
.use(async ({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Authentication required" });
}
if (!ctx.dbUser) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "User account not found" });
}
}
return next({
ctx: {
...ctx,
session: ctx.session,
user: ctx.session.user,
dbUser: ctx.dbUser,
},
// Rate limit by user ID
if (!isE2eTestMode) {
const rateLimitResult = await apiRateLimiter(ctx.dbUser.id);
if (!rateLimitResult.allowed) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: `Rate limit exceeded. Try again after ${rateLimitResult.resetAt.toISOString()}`,
});
}
}
return next({
ctx: {
...ctx,
session: ctx.session,
user: ctx.session.user,
dbUser: ctx.dbUser,
},
});
});
});
/**
* Resource overview procedure — requires broad people-directory visibility.
@@ -191,8 +197,8 @@ export const resourceOverviewProcedure = protectedProcedure.use(({ ctx, next })
);
if (
!permissions.has(PermissionKey.VIEW_ALL_RESOURCES)
&& !permissions.has(PermissionKey.MANAGE_RESOURCES)
!permissions.has(PermissionKey.VIEW_ALL_RESOURCES) &&
!permissions.has(PermissionKey.MANAGE_RESOURCES)
) {
throw new TRPCError({
code: "FORBIDDEN",
@@ -280,7 +286,7 @@ export const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
*/
export function requirePermission(
ctx: { permissions: Set<PermissionKey> },
key: PermissionKey
key: PermissionKey,
): void {
if (!ctx.permissions.has(key)) {
throw new TRPCError({ code: "FORBIDDEN", message: `Permission required: ${key}` });