feat: implement download_resume_pdf mcp tool

This commit is contained in:
Amruth Pillai
2026-06-01 10:26:28 +02:00
parent 9ce5bacd22
commit 0df7f21130
35 changed files with 1951 additions and 1602 deletions
+9 -9
View File
@@ -19,14 +19,14 @@
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.80",
"@ai-sdk/anthropic": "^3.0.81",
"@ai-sdk/google": "^3.0.80",
"@ai-sdk/openai": "^3.0.65",
"@ai-sdk/openai": "^3.0.67",
"@ai-sdk/openai-compatible": "^2.0.48",
"@aws-sdk/client-s3": "^3.1055.0",
"@orpc/client": "^1.14.3",
"@orpc/experimental-ratelimit": "^1.14.3",
"@orpc/server": "^1.14.3",
"@aws-sdk/client-s3": "^3.1057.0",
"@orpc/client": "^1.14.4",
"@orpc/experimental-ratelimit": "^1.14.4",
"@orpc/server": "^1.14.4",
"@reactive-resume/ai": "workspace:*",
"@reactive-resume/auth": "workspace:*",
"@reactive-resume/db": "workspace:*",
@@ -35,9 +35,9 @@
"@reactive-resume/resume": "workspace:*",
"@reactive-resume/schema": "workspace:*",
"@reactive-resume/utils": "workspace:*",
"ai": "^6.0.191",
"ai": "^6.0.193",
"bcrypt": "^6.0.0",
"better-auth": "1.6.11",
"better-auth": "1.6.13",
"drizzle-orm": "1.0.0-rc.3",
"drizzle-zod": "1.0.0-beta.14-a36c63d",
"es-toolkit": "^1.47.0",
@@ -52,7 +52,7 @@
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/bcrypt": "^6.0.0",
"@typescript/native-preview": "7.0.0-dev.20260527.1",
"@typescript/native-preview": "7.0.0-dev.20260527.2",
"typescript": "^6.0.3"
}
}
+32 -16
View File
@@ -6,6 +6,37 @@ import { protectedProcedure } from "../../context";
import { pdfExportRateLimit } from "../../middleware/rate-limit";
import { resumeService } from "./service";
export {
createResumePdfDownloadUrl,
MAX_PDF_DOWNLOAD_URL_TTL_SECONDS,
PDF_DOWNLOAD_URL_EXPIRES_IN_SECONDS,
verifyResumePdfDownloadToken,
} from "./pdf-download-url";
type CreateResumePdfDownloadInput = {
id: string;
userId: string;
};
export async function createResumePdfDownload(input: CreateResumePdfDownloadInput) {
const resume = await resumeService.getById({ id: input.id, userId: input.userId });
const filename = generateFilename(resume.name, "pdf");
try {
const body = await createResumePdfFile({ data: resume.data, filename });
return {
headers: {
"content-disposition": `attachment; filename="${filename}"`,
},
body,
};
} catch (error) {
console.error("[PDF API] Failed to render resume PDF", { resumeId: input.id, error });
throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to generate resume PDF" });
}
}
export const downloadResumePdfProcedure = protectedProcedure
.route({
method: "GET",
@@ -29,20 +60,5 @@ export const downloadResumePdfProcedure = protectedProcedure
)
.use(pdfExportRateLimit)
.handler(async ({ context, input }) => {
const resume = await resumeService.getById({ id: input.id, userId: context.user.id });
const filename = generateFilename(resume.name, "pdf");
try {
const body = await createResumePdfFile({ data: resume.data, filename });
return {
headers: {
"content-disposition": `attachment; filename="${filename}"`,
},
body,
};
} catch (error) {
console.error("[PDF API] Failed to render resume PDF", { resumeId: input.id, error });
throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to generate resume PDF" });
}
return createResumePdfDownload({ id: input.id, userId: context.user.id });
});
@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@reactive-resume/env/server", () => ({
env: {
APP_URL: "https://example.com/app/",
AUTH_SECRET: "test-secret",
},
}));
const { MAX_PDF_DOWNLOAD_URL_TTL_SECONDS, 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", () => {
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();
expect(result.expiresInSeconds).toBe(600);
expect(result.expiresAt).toBe("2026-06-01T10:10:00.000Z");
if (!token) throw new Error("Expected signed URL token");
expect(
verifyResumePdfDownloadToken({
resumeId: "resume-1",
token,
now: new Date("2026-06-01T10:09:59.000Z"),
}),
).toEqual({
ok: true,
resumeId: "resume-1",
userId: "user-1",
expiresAt: "2026-06-01T10:10:00.000Z",
});
});
it("rejects expired, tampered, and mismatched tokens", () => {
const result = createResumePdfDownloadUrl({
resumeId: "resume-1",
userId: "user-1",
now: new Date("2026-06-01T10:00:00.000Z"),
});
const token = new URL(result.url).searchParams.get("token");
if (!token) throw new Error("Expected signed URL token");
expect(
verifyResumePdfDownloadToken({
resumeId: "resume-1",
token,
now: new Date("2026-06-01T10:10:01.000Z"),
}),
).toEqual({ ok: false, reason: "expired" });
expect(
verifyResumePdfDownloadToken({
resumeId: "other-resume",
token,
now: new Date("2026-06-01T10:01:00.000Z"),
}),
).toEqual({ ok: false, reason: "resume_mismatch" });
const tamperedToken = `${token.slice(0, -1)}${token.endsWith("x") ? "y" : "x"}`;
expect(
verifyResumePdfDownloadToken({
resumeId: "resume-1",
token: tamperedToken,
now: new Date("2026-06-01T10:01:00.000Z"),
}),
).toEqual({ ok: false, reason: "invalid_signature" });
});
});
@@ -0,0 +1,127 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { env } from "@reactive-resume/env/server";
export const MAX_PDF_DOWNLOAD_URL_TTL_SECONDS = 10 * 60;
export const PDF_DOWNLOAD_URL_EXPIRES_IN_SECONDS = MAX_PDF_DOWNLOAD_URL_TTL_SECONDS;
type PdfDownloadTokenPayload = {
v: 1;
resumeId: string;
userId: string;
expiresAt: number;
issuedAt: number;
};
type CreateResumePdfDownloadUrlInput = {
resumeId: string;
userId: string;
now?: Date;
ttlSeconds?: number;
};
type VerifyResumePdfDownloadTokenInput = {
resumeId: string;
token: string;
now?: Date;
};
type VerifyResumePdfDownloadTokenResult =
| {
ok: true;
resumeId: string;
userId: string;
expiresAt: string;
}
| {
ok: false;
reason: "expired" | "invalid_signature" | "malformed" | "resume_mismatch";
};
function resolveTtlSeconds(ttlSeconds: number | undefined) {
if (ttlSeconds === undefined || !Number.isFinite(ttlSeconds)) return PDF_DOWNLOAD_URL_EXPIRES_IN_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");
}
function decodeJson(value: string): unknown {
return JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
}
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;
const payload = value as Partial<PdfDownloadTokenPayload>;
if (payload.v !== 1) return null;
if (typeof payload.resumeId !== "string" || payload.resumeId.length === 0) return null;
if (typeof payload.userId !== "string" || payload.userId.length === 0) return null;
if (typeof payload.expiresAt !== "number" || !Number.isFinite(payload.expiresAt)) return null;
if (typeof payload.issuedAt !== "number" || !Number.isFinite(payload.issuedAt)) return null;
return payload as PdfDownloadTokenPayload;
}
export function createResumePdfDownloadUrl({
resumeId,
userId,
now = new Date(),
ttlSeconds,
}: CreateResumePdfDownloadUrlInput) {
const expiresInSeconds = resolveTtlSeconds(ttlSeconds);
const expiresAt = new Date(now.getTime() + expiresInSeconds * 1000);
const payload = encodeJson({
v: 1,
resumeId,
userId,
expiresAt: expiresAt.getTime(),
issuedAt: now.getTime(),
} satisfies PdfDownloadTokenPayload);
const token = `${payload}.${sign(payload)}`;
const url = new URL(`/api/resumes/${encodeURIComponent(resumeId)}/pdf`, env.APP_URL);
url.searchParams.set("token", token);
return {
url: url.toString(),
expiresAt: expiresAt.toISOString(),
expiresInSeconds,
};
}
export function verifyResumePdfDownloadToken({
resumeId,
token,
now = new Date(),
}: 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" };
try {
const parsed = parsePayload(decodeJson(payload));
if (!parsed) return { ok: false, reason: "malformed" };
if (parsed.resumeId !== resumeId) return { ok: false, reason: "resume_mismatch" };
if (parsed.expiresAt <= now.getTime()) return { ok: false, reason: "expired" };
return {
ok: true,
resumeId: parsed.resumeId,
userId: parsed.userId,
expiresAt: new Date(parsed.expiresAt).toISOString(),
};
} catch {
return { ok: false, reason: "malformed" };
}
}