diff --git a/apps/server/src/http/headers.ts b/apps/server/src/http/headers.ts index 472d7ecda..55eda23bb 100644 --- a/apps/server/src/http/headers.ts +++ b/apps/server/src/http/headers.ts @@ -1,13 +1,3 @@ -export function getCookie(request: Request, name: string): string | undefined { - const cookieHeader = request.headers.get("cookie"); - if (!cookieHeader) return; - - for (const part of cookieHeader.split(";")) { - const [rawName, ...rawValue] = part.trim().split("="); - if (rawName === name && rawValue.length > 0) return rawValue.join("="); - } -} - export function mergeResponseHeaders(response: Response, headers: Headers): Response { if ([...headers].length === 0) return response; diff --git a/apps/server/src/http/public-resume-pdf.ts b/apps/server/src/http/public-resume-pdf.ts index 4edd2d4d8..ae0270962 100644 --- a/apps/server/src/http/public-resume-pdf.ts +++ b/apps/server/src/http/public-resume-pdf.ts @@ -15,7 +15,7 @@ export async function handlePublicResumePdf( request: Request, username: string, slug: string, - trustedClient = "unknown", + trustedClient: string, ): Promise { try { const result = await createPublicResumePdf({ diff --git a/apps/server/src/openapi/handler.ts b/apps/server/src/openapi/handler.ts index 19b619860..8f3b59246 100644 --- a/apps/server/src/openapi/handler.ts +++ b/apps/server/src/openapi/handler.ts @@ -25,7 +25,7 @@ const openAPIHandler = new OpenAPIHandler(openAPIRouter, { ], }); -export async function handleOpenApi(request: Request, trustedClient = "unknown") { +export async function handleOpenApi(request: Request, trustedClient: string) { if (request.method === "GET" && (request.url.endsWith("/spec.json") || request.url.endsWith("/spec"))) { return Response.json(await generateOpenApiSpec({ appUrl: env.APP_URL, version: appVersion })); } diff --git a/apps/server/src/rpc/handler.ts b/apps/server/src/rpc/handler.ts index 0707a30d5..5a8dcf68e 100644 --- a/apps/server/src/rpc/handler.ts +++ b/apps/server/src/rpc/handler.ts @@ -14,7 +14,7 @@ const rpcHandler = new RPCHandler(router, { ], }); -export async function handleRpc(request: Request, trustedClient = "unknown") { +export async function handleRpc(request: Request, trustedClient: string) { const resHeaders = new Headers(); const { response } = await rpcHandler.handle(request, { prefix: "/api/rpc", diff --git a/apps/server/src/rpc/locale.ts b/apps/server/src/rpc/locale.ts index c8fe0eaaa..879557d06 100644 --- a/apps/server/src/rpc/locale.ts +++ b/apps/server/src/rpc/locale.ts @@ -1,8 +1,8 @@ import type { Locale } from "@reactive-resume/utils/locale"; +import { parse } from "hono/utils/cookie"; import { defaultLocale, isLocale } from "@reactive-resume/utils/locale"; -import { getCookie } from "../http/headers"; export function getRequestLocale(request: Request): Locale { - const locale = getCookie(request, "locale"); + const locale = parse(request.headers.get("cookie") ?? "", "locale").locale; return isLocale(locale) ? locale : defaultLocale; } diff --git a/apps/server/src/static/web.ts b/apps/server/src/static/web.ts index 82a5035cd..b50676732 100644 --- a/apps/server/src/static/web.ts +++ b/apps/server/src/static/web.ts @@ -259,7 +259,7 @@ export const serveWebDistStatic = serveStatic({ }); function getFallbackResponseHeaders(pathname: string) { - if (pathname === "/" && env.ROOT_RESUME_ID?.trim()) { + if (pathname === "/" && env.ROOT_RESUME_ID) { return { "Content-Type": "text/html; charset=UTF-8", "X-Robots-Tag": "noindex, follow", @@ -308,7 +308,7 @@ export async function handleWebApp(request: Request) { const html = await fs.readFile(indexHtmlPath, "utf-8"); const canonicalUrl = new URL("/", env.APP_URL).toString(); - if (pathname === "/" && env.ROOT_RESUME_ID?.trim()) { + if (pathname === "/" && env.ROOT_RESUME_ID) { // Root configuration never discloses a target in the HTML shell. The public API // gates data and browser metadata; shell requests must not count extra views. const shell = html diff --git a/packages/api/src/features/ai/router.ts b/packages/api/src/features/ai/router.ts index 5f8f85320..c0b6c2039 100644 --- a/packages/api/src/features/ai/router.ts +++ b/packages/api/src/features/ai/router.ts @@ -9,39 +9,28 @@ import { atsReviewInputSchema, atsReviewOutputSchema, reviewResumeText } from ". import { improveInputSchema, improveLine, improveOutputSchema } from "./improve"; import { aiService, fileInputSchema } from "./service"; -function isInvalidAiBaseUrlError(error: unknown): boolean { - return error instanceof Error && error.message === "INVALID_AI_BASE_URL"; +/** + * Every AI procedure fails the same ways: no ENCRYPTION_SECRET, a bad base URL, the provider erroring (its cause kept + * for upstream error reporters), or the model returning a shape that can't be used, named per procedure. + */ +function rethrowAiError(error: unknown, invalidStructure: string): never { + if (error instanceof Error && error.message === "AI_CREDENTIAL_ENCRYPTION_UNAVAILABLE") + throw new ORPCError("PRECONDITION_FAILED", { + message: "AI providers are unavailable because ENCRYPTION_SECRET is not configured.", + }); + if (error instanceof Error && error.message === "INVALID_AI_BASE_URL") + throw new ORPCError("BAD_REQUEST", { message: "Invalid AI provider configuration." }); + if (error instanceof AISDKError) + throw new ORPCError("BAD_GATEWAY", { message: "Could not reach the AI provider.", cause: error }); + if (error instanceof ZodError) + throw new ORPCError("BAD_REQUEST", { message: invalidStructure, cause: flattenError(error) }); + throw error; } -function isAiProviderGatewayError(error: unknown): boolean { - return error instanceof AISDKError; -} - -function isCredentialEncryptionUnavailable(error: unknown): boolean { - return error instanceof Error && error.message === "AI_CREDENTIAL_ENCRYPTION_UNAVAILABLE"; -} - -/** Throws a BAD_GATEWAY ORPCError, preserving the original cause for upstream error reporters. */ -function throwAiProviderGatewayError(cause?: unknown): never { - throw new ORPCError("BAD_GATEWAY", { message: "Could not reach the AI provider.", cause }); -} - -function throwAiProviderConfigError(): never { - throw new ORPCError("BAD_REQUEST", { message: "Invalid AI provider configuration." }); -} - -function throwCredentialEncryptionUnavailable(): never { - throw new ORPCError("PRECONDITION_FAILED", { - message: "AI providers are unavailable because ENCRYPTION_SECRET is not configured.", - }); -} - -function throwResumeStructureError(error: ZodError): never { - throw new ORPCError("BAD_REQUEST", { - message: "Invalid resume data structure", - cause: flattenError(error), - }); -} +const aiErrors = { + BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 }, + BAD_REQUEST: { message: "The AI returned an improperly formatted structure.", status: 400 }, +} as const; async function getRunnableProvider(userId: string, aiProviderId?: string) { const provider = aiProviderId @@ -67,10 +56,7 @@ export const aiRouter = { }) .input(z.object({ aiProviderId: z.string().optional(), file: fileInputSchema })) .use(aiRequestRateLimit) - .errors({ - BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 }, - BAD_REQUEST: { message: "The AI returned an improperly formatted structure.", status: 400 }, - }) + .errors(aiErrors) .handler(async ({ context, input }): Promise => { try { const provider = await getRunnableProvider(context.user.id, input.aiProviderId); @@ -82,11 +68,7 @@ export const aiRouter = { file: input.file, }); } catch (error) { - if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable(); - if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError(); - if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(error); - if (error instanceof ZodError) throwResumeStructureError(error); - throw error; + rethrowAiError(error, "Invalid resume data structure"); } }), @@ -112,10 +94,7 @@ export const aiRouter = { }), ) .use(aiRequestRateLimit) - .errors({ - BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 }, - BAD_REQUEST: { message: "The AI returned an improperly formatted structure.", status: 400 }, - }) + .errors(aiErrors) .handler(async ({ context, input }) => { try { const provider = await getRunnableProvider(context.user.id, input.aiProviderId); @@ -128,11 +107,7 @@ export const aiRouter = { file: input.file, }); } catch (error) { - if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable(); - if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError(); - if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(error); - if (error instanceof ZodError) throwResumeStructureError(error); - throw error; + rethrowAiError(error, "Invalid resume data structure"); } }), @@ -150,10 +125,7 @@ export const aiRouter = { .input(atsReviewInputSchema) .use(aiRequestRateLimit) .output(atsReviewOutputSchema) - .errors({ - BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 }, - BAD_REQUEST: { message: "The AI returned an improperly formatted structure.", status: 400 }, - }) + .errors(aiErrors) .handler(async ({ context, input }) => { try { const provider = await getRunnableProvider(context.user.id, input.aiProviderId); @@ -166,13 +138,7 @@ export const aiRouter = { baseURL: provider.baseURL ?? "", }); } catch (error) { - if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable(); - if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError(); - if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(error); - if (error instanceof ZodError) { - throw new ORPCError("BAD_REQUEST", { message: "Invalid ATS review structure", cause: flattenError(error) }); - } - throw error; + rethrowAiError(error, "Invalid ATS review structure"); } }), @@ -190,10 +156,7 @@ export const aiRouter = { .input(improveInputSchema) .use(aiRequestRateLimit) .output(improveOutputSchema) - .errors({ - BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 }, - BAD_REQUEST: { message: "The AI returned an improperly formatted structure.", status: 400 }, - }) + .errors(aiErrors) .handler(async ({ context, input }) => { try { const provider = await getRunnableProvider(context.user.id, input.aiProviderId); @@ -206,13 +169,7 @@ export const aiRouter = { baseURL: provider.baseURL ?? "", }); } catch (error) { - if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable(); - if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError(); - if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(error); - if (error instanceof ZodError) { - throw new ORPCError("BAD_REQUEST", { message: "Invalid suggestion structure", cause: flattenError(error) }); - } - throw error; + rethrowAiError(error, "Invalid suggestion structure"); } }), }; diff --git a/packages/api/src/features/applications/service.test.ts b/packages/api/src/features/applications/service.test.ts index d09a34943..68b77eb9f 100644 --- a/packages/api/src/features/applications/service.test.ts +++ b/packages/api/src/features/applications/service.test.ts @@ -6,7 +6,6 @@ const dbMock = vi.hoisted(() => ({ insert: vi.fn(), update: vi.fn(), delete: vi.fn(), - execute: vi.fn(), transaction: vi.fn(), })); const resumeGetByIdMock = vi.hoisted(() => vi.fn()); @@ -62,9 +61,10 @@ const existing = { coverLetterUrl: "/api/uploads/user-1/pictures/cover.pdf", }; +// Awaitable directly, or after `.for("update")` for the reads that lock their row. const createSelectChain = (rows: unknown[]) => ({ from: () => ({ - where: () => Promise.resolve(rows), + where: () => Object.assign(Promise.resolve(rows), { for: () => Promise.resolve(rows) }), }), }); @@ -80,7 +80,6 @@ beforeEach(() => { dbMock.insert.mockReset(); dbMock.update.mockReset(); dbMock.delete.mockReset(); - dbMock.execute.mockReset(); dbMock.transaction.mockReset(); dbMock.transaction.mockImplementation((callback) => callback(dbMock)); resumeGetByIdMock.mockReset(); @@ -287,7 +286,6 @@ describe("applicationService timeline entries", () => { at: new Date("2026-07-10T15:45:00.000Z"), }); expect(dbMock.transaction).toHaveBeenCalled(); - expect(dbMock.execute).toHaveBeenCalled(); }); it("normalizes JSONB date strings when editing timeline dates", async () => { @@ -396,7 +394,6 @@ describe("applicationService timeline entries", () => { ).deleteTimelineEntry({ id: "app-1", userId: "user-1", entryId: "stage-2" }), ).rejects.toMatchObject({ code: "BAD_REQUEST" }); expect(dbMock.transaction).toHaveBeenCalled(); - expect(dbMock.execute).toHaveBeenCalled(); }); it("deletes older stage entries", async () => { diff --git a/packages/api/src/features/applications/service.ts b/packages/api/src/features/applications/service.ts index 637609d86..446987077 100644 --- a/packages/api/src/features/applications/service.ts +++ b/packages/api/src/features/applications/service.ts @@ -528,16 +528,11 @@ export const applicationService = { const patch = Object.fromEntries(Object.entries(details).filter(([, value]) => value !== undefined)); return db.transaction(async (tx) => { - await tx.execute(sql` - select 1 from ${schema.application} - where ${schema.application.id} = ${id} and ${schema.application.userId} = ${userId} - for update - `); - const [existing] = await tx .select() .from(schema.application) - .where(and(eq(schema.application.id, id), eq(schema.application.userId, userId))); + .where(and(eq(schema.application.id, id), eq(schema.application.userId, userId))) + .for("update"); if (!existing) throw new ORPCError("NOT_FOUND"); const target = existing.activity.find((entry) => entry.id === entryId); @@ -569,16 +564,11 @@ export const applicationService = { text?: string | undefined; }) => { return db.transaction(async (tx) => { - await tx.execute(sql` - select 1 from ${schema.application} - where ${schema.application.id} = ${input.id} and ${schema.application.userId} = ${input.userId} - for update - `); - const [existing] = await tx .select() .from(schema.application) - .where(and(eq(schema.application.id, input.id), eq(schema.application.userId, input.userId))); + .where(and(eq(schema.application.id, input.id), eq(schema.application.userId, input.userId))) + .for("update"); if (!existing) throw new ORPCError("NOT_FOUND"); const activity = existing.activity.map((entry) => { @@ -621,16 +611,11 @@ export const applicationService = { deleteTimelineEntry: (input: { id: string; userId: string; entryId: string }) => { return db.transaction(async (tx) => { - await tx.execute(sql` - select 1 from ${schema.application} - where ${schema.application.id} = ${input.id} and ${schema.application.userId} = ${input.userId} - for update - `); - const [existing] = await tx .select() .from(schema.application) - .where(and(eq(schema.application.id, input.id), eq(schema.application.userId, input.userId))); + .where(and(eq(schema.application.id, input.id), eq(schema.application.userId, input.userId))) + .for("update"); if (!existing) throw new ORPCError("NOT_FOUND"); const entry = existing.activity.find((item) => item.id === input.entryId); diff --git a/packages/api/src/features/resume/access.ts b/packages/api/src/features/resume/access.ts index 9b814e9b2..59853a259 100644 --- a/packages/api/src/features/resume/access.ts +++ b/packages/api/src/features/resume/access.ts @@ -1,4 +1,5 @@ import { createHash, timingSafeEqual } from "node:crypto"; +import { parseCookies } from "better-auth/cookies"; import { env } from "@reactive-resume/env/server"; const RESUME_ACCESS_COOKIE_PREFIX = "resume_access"; @@ -9,55 +10,27 @@ const getResumeAccessCookieName = (resumeId: string) => `${RESUME_ACCESS_COOKIE_ const signResumeAccessToken = (resumeId: string, passwordHash: string): string => createHash("sha256").update(`${resumeId}:${passwordHash}`).digest("hex"); -const safeEquals = (value: string, expected: string) => { +export const safeEquals = (value: string, expected: string) => { const valueBuffer = Buffer.from(value); const expectedBuffer = Buffer.from(expected); if (valueBuffer.length !== expectedBuffer.length) return false; return timingSafeEqual(valueBuffer, expectedBuffer); }; -const parseCookieHeader = (cookieHeader: string | null): Map => { - const cookies = new Map(); - if (!cookieHeader) return cookies; - - for (const part of cookieHeader.split(";")) { - const [rawName, ...rawValue] = part.trim().split("="); - if (!rawName || rawValue.length === 0) continue; - - cookies.set(rawName, rawValue.join("=")); - } - - return cookies; -}; - -const serializeCookie = ( - name: string, - value: string, - options: { path: string; httpOnly: boolean; sameSite: "lax"; maxAge: number; secure: boolean }, -) => { - const parts = [`${name}=${value}`, `Path=${options.path}`, `Max-Age=${options.maxAge}`, "SameSite=Lax"]; - if (options.httpOnly) parts.push("HttpOnly"); - if (options.secure) parts.push("Secure"); - return parts.join("; "); -}; - export const hasResumeAccess = (requestHeaders: Headers, resumeId: string, passwordHash: string | null) => { if (!passwordHash) return false; const cookieName = getResumeAccessCookieName(resumeId); - const cookieValue = parseCookieHeader(requestHeaders.get("cookie")).get(cookieName); + const cookieValue = parseCookies(requestHeaders.get("cookie") ?? "").get(cookieName); if (!cookieValue) return false; const expected = signResumeAccessToken(resumeId, passwordHash); return safeEquals(cookieValue, expected); }; export const grantResumeAccess = (responseHeaders: Headers, resumeId: string, passwordHash: string) => { - const cookie = serializeCookie(getResumeAccessCookieName(resumeId), signResumeAccessToken(resumeId, passwordHash), { - path: "/", - httpOnly: true, - sameSite: "lax", - maxAge: RESUME_ACCESS_TTL_SECONDS, - secure: env.APP_URL.startsWith("https"), - }); - - responseHeaders.append("Set-Cookie", cookie); + const value = `${getResumeAccessCookieName(resumeId)}=${signResumeAccessToken(resumeId, passwordHash)}`; + const secure = env.APP_URL.startsWith("https") ? "; Secure" : ""; + responseHeaders.append( + "Set-Cookie", + `${value}; Path=/; Max-Age=${RESUME_ACCESS_TTL_SECONDS}; SameSite=Lax; HttpOnly${secure}`, + ); }; diff --git a/packages/api/src/features/resume/export.ts b/packages/api/src/features/resume/export.ts index 6744ca058..1df839037 100644 --- a/packages/api/src/features/resume/export.ts +++ b/packages/api/src/features/resume/export.ts @@ -6,11 +6,7 @@ import { pdfExportRateLimit } from "../../middleware/rate-limit"; import { parseStoredResumeData } from "./resume-data-validation"; import { resumeService } from "./service"; -export { - createResumePdfDownloadUrl, - MAX_PDF_DOWNLOAD_URL_TTL_SECONDS, - verifyResumePdfDownloadToken, -} from "./pdf-download-url"; +export { createResumePdfDownloadUrl, verifyResumePdfDownloadToken } from "./pdf-download-url"; type CreateResumePdfDownloadInput = { id: string; diff --git a/packages/api/src/features/resume/pdf-download-url.test.ts b/packages/api/src/features/resume/pdf-download-url.test.ts index b13b660d5..ca087f3fc 100644 --- a/packages/api/src/features/resume/pdf-download-url.test.ts +++ b/packages/api/src/features/resume/pdf-download-url.test.ts @@ -8,25 +8,21 @@ vi.mock("@reactive-resume/env/server", () => ({ }, })); -const { MAX_PDF_DOWNLOAD_URL_TTL_SECONDS, createResumePdfDownloadUrl, verifyResumePdfDownloadToken } = await import( - "./pdf-download-url" -); +const { createResumePdfDownloadUrl, verifyResumePdfDownloadToken } = await import("./pdf-download-url"); describe("resume PDF signed download URLs", () => { - it("creates a URL with a token that is capped at 10 minutes", () => { + it("creates a URL with a token that lasts 10 minutes", () => { const now = new Date("2026-06-01T10:00:00.000Z"); const result = createResumePdfDownloadUrl({ resumeId: "resume-1", userId: "user-1", now, - ttlSeconds: 60 * 60, }); const url = new URL(result.url); const token = url.searchParams.get("token"); - expect(MAX_PDF_DOWNLOAD_URL_TTL_SECONDS).toBe(600); expect(url.origin).toBe("https://example.com"); expect(url.pathname).toBe("/api/resumes/resume-1/pdf"); expect(token).toBeTruthy(); diff --git a/packages/api/src/features/resume/pdf-download-url.ts b/packages/api/src/features/resume/pdf-download-url.ts index 54f783f42..f5e3f901b 100644 --- a/packages/api/src/features/resume/pdf-download-url.ts +++ b/packages/api/src/features/resume/pdf-download-url.ts @@ -1,7 +1,9 @@ -import { createHmac, timingSafeEqual } from "node:crypto"; +import { createHmac } from "node:crypto"; import { env } from "@reactive-resume/env/server"; +import { safeEquals } from "./access"; -export const MAX_PDF_DOWNLOAD_URL_TTL_SECONDS = 10 * 60; +// Long enough to click, short enough that a leaked link is useless soon after. +const TTL_SECONDS = 10 * 60; type PdfDownloadTokenPayload = { v: 1; @@ -15,7 +17,6 @@ type CreateResumePdfDownloadUrlInput = { resumeId: string; userId: string; now?: Date; - ttlSeconds?: number; }; type VerifyResumePdfDownloadTokenInput = { @@ -36,11 +37,6 @@ type VerifyResumePdfDownloadTokenResult = reason: "expired" | "invalid_signature" | "malformed" | "resume_mismatch"; }; -function resolveTtlSeconds(ttlSeconds: number | undefined) { - if (ttlSeconds === undefined || !Number.isFinite(ttlSeconds)) return MAX_PDF_DOWNLOAD_URL_TTL_SECONDS; - return Math.min(Math.max(Math.floor(ttlSeconds), 1), MAX_PDF_DOWNLOAD_URL_TTL_SECONDS); -} - function encodeJson(value: unknown) { return Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); } @@ -53,13 +49,6 @@ function sign(payload: string) { return createHmac("sha256", env.AUTH_SECRET).update(payload).digest("base64url"); } -function signaturesMatch(actual: string, expected: string) { - const actualBuffer = Buffer.from(actual); - const expectedBuffer = Buffer.from(expected); - - return actualBuffer.byteLength === expectedBuffer.byteLength && timingSafeEqual(actualBuffer, expectedBuffer); -} - function parsePayload(value: unknown): PdfDownloadTokenPayload | null { if (!value || typeof value !== "object") return null; @@ -73,13 +62,8 @@ function parsePayload(value: unknown): PdfDownloadTokenPayload | null { return payload as PdfDownloadTokenPayload; } -export function createResumePdfDownloadUrl({ - resumeId, - userId, - now = new Date(), - ttlSeconds, -}: CreateResumePdfDownloadUrlInput) { - const expiresInSeconds = resolveTtlSeconds(ttlSeconds); +export function createResumePdfDownloadUrl({ resumeId, userId, now = new Date() }: CreateResumePdfDownloadUrlInput) { + const expiresInSeconds = TTL_SECONDS; const expiresAt = new Date(now.getTime() + expiresInSeconds * 1000); const payload = encodeJson({ v: 1, @@ -106,7 +90,7 @@ export function verifyResumePdfDownloadToken({ }: VerifyResumePdfDownloadTokenInput): VerifyResumePdfDownloadTokenResult { const [payload, signature, extra] = token.split("."); if (!payload || !signature || extra !== undefined) return { ok: false, reason: "malformed" }; - if (!signaturesMatch(signature, sign(payload))) return { ok: false, reason: "invalid_signature" }; + if (!safeEquals(signature, sign(payload))) return { ok: false, reason: "invalid_signature" }; try { const parsed = parsePayload(decodeJson(payload)); diff --git a/packages/db/src/redis.ts b/packages/db/src/redis.ts index c6b890a0a..da3323c18 100644 --- a/packages/db/src/redis.ts +++ b/packages/db/src/redis.ts @@ -4,7 +4,7 @@ import { env } from "@reactive-resume/env/server"; let redis: Redis | undefined; export function getRedis(): Redis | null { - const url = env.REDIS_URL?.trim(); + const url = env.REDIS_URL; if (!url) return null; if (!redis) { redis = new Redis(url, { diff --git a/packages/mcp/src/tools.test.ts b/packages/mcp/src/tools.test.ts index a481c0592..4dedff798 100644 --- a/packages/mcp/src/tools.test.ts +++ b/packages/mcp/src/tools.test.ts @@ -13,7 +13,6 @@ vi.mock("@reactive-resume/api/context", () => ({ })); vi.mock("@reactive-resume/api/features/resume/export", () => ({ - MAX_PDF_DOWNLOAD_URL_TTL_SECONDS: 600, createResumePdfDownloadUrl: mocks.createResumePdfDownloadUrl, }));