feat: better ratelimiting (#2520)

Replace hono-rate-limiter with a Prisma/PostgreSQL bucketed counter
approach that works correctly across multiple instances without sticky
sessions.

- Add RateLimit model with composite PK (key, action, bucket) and atomic
upsert
- Create rate limit factory with window parsing, bucket computation, and
fail-open
- Define auth-tier and API-tier rate limit instances
- Add Hono middleware, rateLimitResponse helper, and tRPC
assertRateLimit helper
- Wire rate limit headers through AppError constructor (was declared but
never assigned)
- Apply rate limits to auth routes (email-password, passkey), tRPC
routes
  (2FA email, link org account), API routes, and file upload endpoints
- Add cleanup cron job for expired rate limit rows (batched delete every
15 min)
- Remove hono-rate-limiter dependency
This commit is contained in:
Lucas Smith
2026-02-20 12:23:02 +11:00
committed by GitHub
parent 006b1d0a57
commit 653ab3678a
21 changed files with 1218 additions and 62 deletions
+100 -3
View File
@@ -2,6 +2,7 @@ import { sValidator } from '@hono/standard-validator';
import { compare } from '@node-rs/bcrypt';
import { UserSecurityAuditLogType } from '@prisma/client';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { DateTime } from 'luxon';
import { z } from 'zod';
@@ -14,6 +15,15 @@ import { isTwoFactorAuthenticationEnabled } from '@documenso/lib/server-only/2fa
import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa';
import { validateTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/validate-2fa';
import { viewBackupCodes } from '@documenso/lib/server-only/2fa/view-backup-codes';
import { rateLimitResponse } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
import {
forgotPasswordRateLimit,
loginRateLimit,
resendVerifyEmailRateLimit,
resetPasswordRateLimit,
signupRateLimit,
verifyEmailRateLimit,
} from '@documenso/lib/server-only/rate-limit/rate-limits';
import { createUser } from '@documenso/lib/server-only/user/create-user';
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password';
import { getMostRecentEmailVerificationToken } from '@documenso/lib/server-only/user/get-most-recent-email-verification-token';
@@ -51,6 +61,19 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
const { email, password, totpCode, backupCode, csrfToken } = c.req.valid('json');
const loginLimitResult = await loginRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
identifier: email,
});
const loginLimited = rateLimitResponse(c, loginLimitResult);
if (loginLimited) {
throw new HTTPException(429, {
res: loginLimited,
});
}
const csrfCookieToken = await getCsrfCookie(c);
// Todo: (RR7) Add logging here.
@@ -152,6 +175,8 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
* Signup endpoint.
*/
.post('/signup', sValidator('json', ZSignUpSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') {
throw new AppError('SIGNUP_DISABLED', {
message: 'Signups are disabled.',
@@ -160,6 +185,18 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
const { name, email, password, signature } = c.req.valid('json');
const signupLimitResult = await signupRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
});
const signupLimited = rateLimitResponse(c, signupLimitResult);
if (signupLimited) {
throw new HTTPException(429, {
res: signupLimited,
});
}
const user = await createUser({ name, email, password, signature }).catch((err) => {
console.error(err);
throw err;
@@ -219,7 +256,24 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
* Verify email endpoint.
*/
.post('/verify-email', sValidator('json', ZVerifyEmailSchema), async (c) => {
const { state, userId } = await verifyEmail({ token: c.req.valid('json').token });
const requestMetadata = c.get('requestMetadata');
const { token } = c.req.valid('json');
const verifyLimitResult = await verifyEmailRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
identifier: token,
});
const verifyLimited = rateLimitResponse(c, verifyLimitResult);
if (verifyLimited) {
throw new HTTPException(429, {
res: verifyLimited,
});
}
const { state, userId } = await verifyEmail({ token });
// If email is verified, automatically authenticate user.
if (state === EMAIL_VERIFICATION_STATE.VERIFIED && userId !== null) {
@@ -234,8 +288,23 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
* Resend verification email endpoint.
*/
.post('/resend-verify-email', sValidator('json', ZResendVerifyEmailSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
const { email } = c.req.valid('json');
const resendLimitResult = await resendVerifyEmailRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
identifier: email,
});
const resendLimited = rateLimitResponse(c, resendLimitResult);
if (resendLimited) {
throw new HTTPException(429, {
res: resendLimited,
});
}
await jobsClient.triggerJob({
name: 'send.signup.confirmation.email',
payload: {
@@ -249,8 +318,23 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
* Forgot password endpoint.
*/
.post('/forgot-password', sValidator('json', ZForgotPasswordSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
const { email } = c.req.valid('json');
const forgotLimitResult = await forgotPasswordRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
identifier: email,
});
const forgotLimited = rateLimitResponse(c, forgotLimitResult);
if (forgotLimited) {
throw new HTTPException(429, {
res: forgotLimited,
});
}
if (
email.toLowerCase() === legacyServiceAccountEmail() ||
email.toLowerCase() === deletedServiceAccountEmail()
@@ -268,8 +352,23 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
* Reset password endpoint.
*/
.post('/reset-password', sValidator('json', ZResetPasswordSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
const { token, password } = c.req.valid('json');
const resetLimitResult = await resetPasswordRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
identifier: token,
});
const resetLimited = rateLimitResponse(c, resetLimitResult);
if (resetLimited) {
throw new HTTPException(429, {
res: resetLimited,
});
}
const user = await getUserByResetToken({ token });
if (
@@ -279,8 +378,6 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
return c.text('FORBIDDEN', 403);
}
const requestMetadata = c.get('requestMetadata');
const { userId } = await resetPassword({
token,
password,
+15
View File
@@ -3,8 +3,11 @@ import { UserSecurityAuditLogType } from '@prisma/client';
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { rateLimitResponse } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
import { passkeyRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits';
import { deletedServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/deleted-account';
import { legacyServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/legacy-service-account';
import type { TAuthenticationResponseJSONSchema } from '@documenso/lib/types/webauthn';
@@ -23,6 +26,18 @@ export const passkeyRoute = new Hono<HonoAuthContext>()
.post('/authorize', sValidator('json', ZPasskeyAuthorizeSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
const passkeyLimitResult = await passkeyRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
});
const passkeyLimited = rateLimitResponse(c, passkeyLimitResult);
if (passkeyLimited) {
throw new HTTPException(429, {
res: passkeyLimited,
});
}
const { csrfToken, credential } = c.req.valid('json');
if (typeof csrfToken !== 'string' || csrfToken.length === 0) {
+8
View File
@@ -64,6 +64,11 @@ type AppErrorOptions = {
* Mainly used for API -> Frontend communication and logging filtering.
*/
statusCode?: number;
/**
* Optional headers to include when this error is returned in an API response.
*/
headers?: Record<string, string>;
};
export class AppError extends Error {
@@ -82,6 +87,8 @@ export class AppError extends Error {
*/
statusCode?: number;
headers?: Record<string, string>;
name = 'AppError';
/**
@@ -97,6 +104,7 @@ export class AppError extends Error {
this.code = errorCode;
this.userMessage = options?.userMessage;
this.statusCode = options?.statusCode;
this.headers = options?.headers;
}
/**
+2
View File
@@ -11,6 +11,7 @@ import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-sig
import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email';
import { BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION } from './definitions/internal/backport-subscription-claims';
import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
import { CLEANUP_RATE_LIMITS_JOB_DEFINITION } from './definitions/internal/cleanup-rate-limits';
import { EXECUTE_WEBHOOK_JOB_DEFINITION } from './definitions/internal/execute-webhook';
import { EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION } from './definitions/internal/expire-recipients-sweep';
import { PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION } from './definitions/internal/process-recipient-expired';
@@ -37,6 +38,7 @@ export const jobsClient = new JobClient([
EXECUTE_WEBHOOK_JOB_DEFINITION,
EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION,
PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION,
CLEANUP_RATE_LIMITS_JOB_DEFINITION,
] as const);
export const jobs = jobsClient;
@@ -0,0 +1,36 @@
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import type { JobRunIO } from '../../client/_internal/job';
import type { TCleanupRateLimitsJobDefinition } from './cleanup-rate-limits';
const BATCH_SIZE = 10_000;
export const run = async ({ io }: { payload: TCleanupRateLimitsJobDefinition; io: JobRunIO }) => {
const cutoff = DateTime.now().minus({ hours: 24 }).toJSDate();
let totalDeleted = 0;
let deleted = 0;
do {
// Prisma doesn't support DELETE with LIMIT, so use raw SQL for batching
// to avoid long-running transactions that could lock the table.
deleted = await prisma.$executeRaw`
DELETE FROM "RateLimit"
WHERE ctid IN (
SELECT ctid FROM "RateLimit"
WHERE "createdAt" < ${cutoff}
LIMIT ${BATCH_SIZE}
)
`;
totalDeleted += deleted;
} while (deleted >= BATCH_SIZE);
if (totalDeleted > 0) {
io.logger.info(`Cleaned up ${totalDeleted} expired rate limit entries`);
} else {
io.logger.info('No expired rate limit entries to clean up');
}
};
@@ -0,0 +1,30 @@
import { z } from 'zod';
import { type JobDefinition } from '../../client/_internal/job';
const CLEANUP_RATE_LIMITS_JOB_DEFINITION_ID = 'internal.cleanup-rate-limits';
const CLEANUP_RATE_LIMITS_JOB_DEFINITION_SCHEMA = z.object({});
export type TCleanupRateLimitsJobDefinition = z.infer<
typeof CLEANUP_RATE_LIMITS_JOB_DEFINITION_SCHEMA
>;
export const CLEANUP_RATE_LIMITS_JOB_DEFINITION = {
id: CLEANUP_RATE_LIMITS_JOB_DEFINITION_ID,
name: 'Cleanup Rate Limits',
version: '1.0.0',
trigger: {
name: CLEANUP_RATE_LIMITS_JOB_DEFINITION_ID,
schema: CLEANUP_RATE_LIMITS_JOB_DEFINITION_SCHEMA,
cron: '*/15 * * * *', // Every 15 minutes.
},
handler: async ({ payload, io }) => {
const handler = await import('./cleanup-rate-limits.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof CLEANUP_RATE_LIMITS_JOB_DEFINITION_ID,
TCleanupRateLimitsJobDefinition
>;
@@ -0,0 +1,95 @@
import type { Context } from 'hono';
import type { MiddlewareHandler } from 'hono/types';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { getIpAddress } from '../../universal/get-ip-address';
import type { RateLimitCheckResult } from './rate-limit';
import type { createRateLimit } from './rate-limit';
/**
* Set rate limit response headers on a Hono context.
*/
const setRateLimitHeaders = (c: Context, result: RateLimitCheckResult) => {
c.header('X-RateLimit-Limit', String(result.limit));
c.header('X-RateLimit-Remaining', String(result.remaining));
c.header('X-RateLimit-Reset', String(Math.ceil(result.reset.getTime() / 1000)));
};
/**
* Create a Hono middleware that applies rate limiting to a route.
*
* Uses IP address for identification. Optionally accepts an identifier
* function for per-user/per-entity limiting.
*/
export const createRateLimitMiddleware = (
limiter: ReturnType<typeof createRateLimit>,
options?: { identifierFn?: (c: Context) => string | undefined },
): MiddlewareHandler => {
return async (c, next) => {
let ip: string;
try {
ip = getIpAddress(c.req.raw);
} catch {
ip = 'unknown';
}
const identifier = options?.identifierFn?.(c);
const result = await limiter.check({ ip, identifier });
setRateLimitHeaders(c, result);
if (result.isLimited) {
c.header(
'Retry-After',
String(Math.max(1, Math.ceil((result.reset.getTime() - Date.now()) / 1000))),
);
return c.json({ error: 'Too many requests, please try again later.' }, 429);
}
await next();
};
};
/**
* Helper for inline rate limit checks in Hono auth routes.
*
* Returns a 429 Response with rate limit headers if limited, or `null` if allowed.
*/
export const rateLimitResponse = (c: Context, result: RateLimitCheckResult): Response | null => {
setRateLimitHeaders(c, result);
if (result.isLimited) {
c.header(
'Retry-After',
String(Math.max(1, Math.ceil((result.reset.getTime() - Date.now()) / 1000))),
);
return c.json({ error: 'Too many requests, please try again later.' }, 429);
}
return null;
};
/**
* Helper for inline rate limit checks in tRPC routes.
*
* Throws an AppError with TOO_MANY_REQUESTS code if limited.
*/
export const assertRateLimit = (result: RateLimitCheckResult): void => {
if (result.isLimited) {
const retryAfter = String(Math.max(1, Math.ceil((result.reset.getTime() - Date.now()) / 1000)));
throw new AppError(AppErrorCode.TOO_MANY_REQUESTS, {
message: 'Too many requests, please try again later.',
headers: {
'X-RateLimit-Limit': String(result.limit),
'X-RateLimit-Remaining': String(result.remaining),
'X-RateLimit-Reset': String(Math.ceil(result.reset.getTime() / 1000)),
'Retry-After': retryAfter,
},
});
}
};
@@ -0,0 +1,197 @@
import { prisma } from '@documenso/prisma';
import { logger } from '../../utils/logger';
type WindowUnit = 's' | 'm' | 'h' | 'd';
type WindowStr = `${number}${WindowUnit}`;
type RateLimitConfig = {
action: string;
max: number;
globalMax?: number;
window: WindowStr;
};
type CheckParams = {
ip: string;
identifier?: string;
};
export type RateLimitCheckResult = {
isLimited: boolean;
remaining: number;
limit: number;
reset: Date;
};
/**
* Parse window string (e.g., '1h', '15m', '30s') to milliseconds.
*/
export const parseWindow = (window: WindowStr): number => {
const value = parseInt(window.slice(0, -1), 10);
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const unit = window.slice(-1) as WindowUnit;
const multipliers: Record<WindowUnit, number> = {
s: 1000,
m: 60 * 1000,
h: 60 * 60 * 1000,
d: 24 * 60 * 60 * 1000,
};
return value * multipliers[unit];
};
/**
* Compute the current time bucket for the given window size.
*/
export const getBucket = (windowMs: number): Date => {
const now = Date.now();
return new Date(now - (now % windowMs));
};
/**
* Create a rate limiter with the given configuration.
*
* Uses bucketed counters in the database for distributed rate limiting
* across multiple instances. Each check atomically increments the counter
* and returns the new count.
*/
export const createRateLimit = (config: RateLimitConfig) => {
const windowMs = parseWindow(config.window);
return {
async check(params: CheckParams): Promise<RateLimitCheckResult> {
const bucket = getBucket(windowMs);
const reset = new Date(bucket.getTime() + windowMs);
const ipLimit = config.globalMax ?? config.max;
if (process.env.DANGEROUS_BYPASS_RATE_LIMITS === 'true') {
return {
isLimited: false,
remaining: ipLimit,
limit: ipLimit,
reset,
};
}
try {
// Always upsert the IP counter.
const ipResult = await prisma.rateLimit.upsert({
where: {
key_action_bucket: {
key: `ip:${params.ip}`,
action: config.action,
bucket,
},
},
create: {
key: `ip:${params.ip}`,
action: config.action,
bucket,
count: 1,
},
update: {
count: { increment: 1 },
},
});
// Check IP against globalMax if set, or against max if no identifier is provided.
let ipCheckLimit = config.globalMax;
if (!params.identifier) {
ipCheckLimit = config.max;
}
if (ipCheckLimit && ipResult.count > ipCheckLimit) {
logger.warn({
msg: 'Rate limit exceeded',
action: config.action,
keyType: 'ip',
key: params.ip,
count: ipResult.count,
limit: ipCheckLimit,
});
return {
isLimited: true,
remaining: 0,
limit: ipCheckLimit,
reset,
};
}
// Upsert the identifier counter if provided.
if (params.identifier) {
const identifierResult = await prisma.rateLimit.upsert({
where: {
key_action_bucket: {
key: `id:${params.identifier}`,
action: config.action,
bucket,
},
},
create: {
key: `id:${params.identifier}`,
action: config.action,
bucket,
count: 1,
},
update: {
count: { increment: 1 },
},
});
if (identifierResult.count > config.max) {
logger.warn({
msg: 'Rate limit exceeded',
action: config.action,
keyType: 'identifier',
key: params.identifier,
count: identifierResult.count,
limit: config.max,
});
return {
isLimited: true,
remaining: 0,
limit: config.max,
reset,
};
}
return {
isLimited: false,
remaining: Math.max(0, config.max - identifierResult.count),
limit: config.max,
reset,
};
}
return {
isLimited: false,
remaining: Math.max(0, ipLimit - ipResult.count),
limit: ipLimit,
reset,
};
} catch (error) {
// Fail-open: if the rate limit DB query fails, allow the request through.
logger.error({
msg: 'Rate limit check failed, failing open',
action: config.action,
error,
});
const limit = params.identifier ? config.max : ipLimit;
return {
isLimited: false,
remaining: limit,
limit,
reset,
};
}
},
};
};
@@ -0,0 +1,99 @@
import { createRateLimit } from './rate-limit';
// ---- Auth (Tier 1 - Critical, sends emails) ----
export const signupRateLimit = createRateLimit({
action: 'auth.signup',
max: 10,
window: '1h',
});
export const forgotPasswordRateLimit = createRateLimit({
action: 'auth.forgot-password',
max: 3,
globalMax: 20,
window: '1h',
});
export const resendVerifyEmailRateLimit = createRateLimit({
action: 'auth.resend-verify-email',
max: 3,
globalMax: 20,
window: '1h',
});
export const request2FAEmailRateLimit = createRateLimit({
action: 'auth.request-2fa-email',
max: 5,
globalMax: 20,
window: '15m',
});
// ---- Auth (Tier 2 - Unauthenticated) ----
export const loginRateLimit = createRateLimit({
action: 'auth.login',
max: 10,
globalMax: 50,
window: '15m',
});
export const resetPasswordRateLimit = createRateLimit({
action: 'auth.reset-password',
max: 5,
globalMax: 20,
window: '1h',
});
export const verifyEmailRateLimit = createRateLimit({
action: 'auth.verify-email',
max: 5,
globalMax: 20,
window: '15m',
});
export const passkeyRateLimit = createRateLimit({
action: 'auth.passkey',
max: 10,
globalMax: 50,
window: '15m',
});
export const linkOrgAccountRateLimit = createRateLimit({
action: 'auth.link-org-account',
max: 5,
globalMax: 20,
window: '1h',
});
// ---- API (Tier 4 - Standard) ----
export const apiV1RateLimit = createRateLimit({
action: 'api.v1',
max: 100,
window: '1m',
});
export const apiV2RateLimit = createRateLimit({
action: 'api.v2',
max: 100,
window: '1m',
});
export const apiTrpcRateLimit = createRateLimit({
action: 'api.trpc',
max: 100,
window: '1m',
});
export const aiRateLimit = createRateLimit({
action: 'api.ai',
max: 3,
window: '1m',
});
export const fileUploadRateLimit = createRateLimit({
action: 'api.file-upload',
max: 20,
window: '1m',
});
@@ -0,0 +1,13 @@
-- CreateTable
CREATE TABLE "RateLimit" (
"key" TEXT NOT NULL,
"action" TEXT NOT NULL,
"bucket" TIMESTAMP(3) NOT NULL,
"count" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RateLimit_pkey" PRIMARY KEY ("key","action","bucket")
);
-- CreateIndex
CREATE INDEX "RateLimit_createdAt_idx" ON "RateLimit"("createdAt");
+12
View File
@@ -1086,3 +1086,15 @@ model Counter {
id String @id
value Int
}
model RateLimit {
key String
action String
bucket DateTime
count Int @default(1)
createdAt DateTime @default(now())
@@id([key, action, bucket])
@@index([createdAt])
}
@@ -4,6 +4,8 @@ import { DateTime } from 'luxon';
import { TWO_FACTOR_EMAIL_EXPIRATION_MINUTES } from '@documenso/lib/server-only/2fa/email/constants';
import { send2FATokenEmail } from '@documenso/lib/server-only/2fa/email/send-2fa-token-email';
import { assertRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
import { request2FAEmailRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits';
import { DocumentAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
@@ -21,6 +23,13 @@ export const accessAuthRequest2FAEmailRoute = procedure
try {
const { token } = input;
const rateLimitResult = await request2FAEmailRateLimit.check({
ip: ctx.metadata.requestMetadata.ipAddress ?? 'unknown',
identifier: token,
});
assertRateLimit(rateLimitResult);
const user = ctx.user;
// Get document and recipient by token
@@ -1,4 +1,6 @@
import { linkOrganisationAccount } from '@documenso/ee/server-only/lib/link-organisation-account';
import { assertRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
import { linkOrgAccountRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits';
import { procedure } from '../trpc';
import {
@@ -15,6 +17,13 @@ export const linkOrganisationAccountRoute = procedure
.mutation(async ({ input, ctx }) => {
const { token } = input;
const rateLimitResult = await linkOrgAccountRateLimit.check({
ip: ctx.metadata.requestMetadata.ipAddress ?? 'unknown',
identifier: token,
});
assertRateLimit(rateLimitResult);
await linkOrganisationAccount({
token,
requestMeta: ctx.metadata.requestMetadata,
+7 -1
View File
@@ -37,7 +37,7 @@ const t = initTRPC
.create({
transformer: dataTransformer,
errorFormatter(opts) {
const { shape, error } = opts;
const { shape, error, ctx } = opts;
const originalError = error.cause;
@@ -46,6 +46,12 @@ const t = initTRPC
// Default unknown errors to 400, since if you're throwing an AppError it is expected
// that you already know what you're doing.
if (originalError instanceof AppError) {
if (originalError.headers && ctx) {
for (const [headerKey, headerValue] of Object.entries(originalError.headers)) {
ctx.res.headers.append(headerKey, headerValue);
}
}
data = {
...data,
appError: AppError.toJSON(originalError),