mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-06-22 04:11:55 +10:00
feat: implement download_resume_pdf mcp tool
This commit is contained in:
+20
-20
@@ -17,24 +17,24 @@
|
||||
"#react-pdf-renderer": "@react-pdf/renderer"
|
||||
},
|
||||
"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",
|
||||
"@better-auth/api-key": "^1.6.11",
|
||||
"@better-auth/drizzle-adapter": "^1.6.11",
|
||||
"@better-auth/infra": "^0.2.10",
|
||||
"@better-auth/oauth-provider": "^1.6.11",
|
||||
"@better-auth/passkey": "^1.6.11",
|
||||
"@aws-sdk/client-s3": "^3.1057.0",
|
||||
"@better-auth/api-key": "^1.6.13",
|
||||
"@better-auth/drizzle-adapter": "^1.6.13",
|
||||
"@better-auth/infra": "^0.2.11",
|
||||
"@better-auth/oauth-provider": "^1.6.13",
|
||||
"@better-auth/passkey": "^1.6.13",
|
||||
"@hono/node-server": "^2.0.4",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@orpc/client": "^1.14.3",
|
||||
"@orpc/experimental-ratelimit": "^1.14.3",
|
||||
"@orpc/json-schema": "^1.14.3",
|
||||
"@orpc/openapi": "^1.14.3",
|
||||
"@orpc/server": "^1.14.3",
|
||||
"@orpc/zod": "^1.14.3",
|
||||
"@orpc/client": "^1.14.4",
|
||||
"@orpc/experimental-ratelimit": "^1.14.4",
|
||||
"@orpc/json-schema": "^1.14.4",
|
||||
"@orpc/openapi": "^1.14.4",
|
||||
"@orpc/server": "^1.14.4",
|
||||
"@orpc/zod": "^1.14.4",
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@reactive-resume/api": "workspace:*",
|
||||
"@reactive-resume/auth": "workspace:*",
|
||||
@@ -46,9 +46,9 @@
|
||||
"@sindresorhus/slugify": "^3.0.0",
|
||||
"@t3-oss/env-core": "^0.13.11",
|
||||
"@uiw/color-convert": "^2.10.3",
|
||||
"ai": "^6.0.191",
|
||||
"ai": "^6.0.193",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-auth": "1.6.11",
|
||||
"better-auth": "1.6.13",
|
||||
"cjk-regex": "^3.4.0",
|
||||
"deepmerge-ts": "^7.1.5",
|
||||
"dompurify": "^3.4.7",
|
||||
@@ -60,7 +60,7 @@
|
||||
"hono": "^4.12.23",
|
||||
"jsonrepair": "^3.14.0",
|
||||
"node-html-parser": "^7.1.0",
|
||||
"nodemailer": "^8.0.9",
|
||||
"nodemailer": "^8.0.10",
|
||||
"ollama-ai-provider-v2": "^3.5.1",
|
||||
"pg": "^8.21.0",
|
||||
"phosphor-icons-react-pdf": "^0.1.3",
|
||||
@@ -79,9 +79,9 @@
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.2.15",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.1",
|
||||
"tsdown": "^0.22.0",
|
||||
"tsx": "^4.22.3",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.2",
|
||||
"tsdown": "^0.22.1",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.7"
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
|
||||
handleHealth: vi.fn(),
|
||||
handleUpload: vi.fn(),
|
||||
handleMcp: vi.fn(),
|
||||
handleResumePdfDownload: vi.fn(),
|
||||
handleMcpServerCard: vi.fn(),
|
||||
handleOAuthAuthorizationServer: vi.fn(),
|
||||
handleOAuthProtectedResource: vi.fn(),
|
||||
@@ -66,6 +67,10 @@ vi.mock("../mcp/handler", () => ({
|
||||
handleMcp: mocks.handleMcp,
|
||||
}));
|
||||
|
||||
vi.mock("./resume-pdf", () => ({
|
||||
handleResumePdfDownload: mocks.handleResumePdfDownload,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.handleAuth.mockResolvedValue(new Response("auth"));
|
||||
@@ -75,6 +80,7 @@ beforeEach(() => {
|
||||
mocks.handleHealth.mockReturnValue(new Response("health"));
|
||||
mocks.handleUpload.mockResolvedValue(new Response("upload"));
|
||||
mocks.handleMcp.mockResolvedValue(new Response("mcp"));
|
||||
mocks.handleResumePdfDownload.mockResolvedValue(new Response("pdf"));
|
||||
mocks.handleMcpServerCard.mockReturnValue(new Response("server-card"));
|
||||
mocks.handleOAuthAuthorizationServer.mockReturnValue(new Response("oauth-authorization-server"));
|
||||
mocks.handleOAuthProtectedResource.mockReturnValue(new Response("oauth-protected-resource"));
|
||||
@@ -101,6 +107,19 @@ describe("createApp", () => {
|
||||
expect(mocks.handleAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes signed resume PDF downloads before the web fallback", async () => {
|
||||
const { createApp } = await import("./app");
|
||||
const app = createApp();
|
||||
const request = new Request("http://localhost:3001/api/resumes/resume-1/pdf?token=signed");
|
||||
|
||||
const response = await app.fetch(request);
|
||||
|
||||
await expect(response.text()).resolves.toBe("pdf");
|
||||
expect(mocks.handleResumePdfDownload).toHaveBeenCalledWith(request, "resume-1");
|
||||
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
|
||||
expect(mocks.handleWebApp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["GET", "/robots.txt", "robots", mocks.handleRobots],
|
||||
["HEAD", "/robots.txt", "", mocks.handleRobots],
|
||||
|
||||
@@ -15,6 +15,7 @@ import { handleUpload } from "../static/uploads";
|
||||
import { handleWebApp, handleWebAppHead, serveWebDistStatic } from "../static/web";
|
||||
import { handleAuth, handleOAuth } from "./auth";
|
||||
import { handleHealth } from "./health";
|
||||
import { handleResumePdfDownload } from "./resume-pdf";
|
||||
|
||||
export function createApp() {
|
||||
const app = new Hono();
|
||||
@@ -26,6 +27,7 @@ export function createApp() {
|
||||
app.get("/api/auth/oauth", (c) => handleOAuth(c.req.raw));
|
||||
app.all("/api/auth/*", (c) => handleAuth(c.req.raw));
|
||||
app.get("/api/health", () => handleHealth());
|
||||
app.get("/api/resumes/:id/pdf", (c) => handleResumePdfDownload(c.req.raw, c.req.param("id")));
|
||||
app.get("/api/uploads/*", (c) => handleUpload(c.req.raw));
|
||||
app.get("/uploads/*", (c) => handleUpload(c.req.raw));
|
||||
app.get("/schema.json", () => handleSchemaJson());
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createResumePdfDownload: vi.fn(),
|
||||
verifyResumePdfDownloadToken: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/api/features/resume/export", () => ({
|
||||
createResumePdfDownload: mocks.createResumePdfDownload,
|
||||
verifyResumePdfDownloadToken: mocks.verifyResumePdfDownloadToken,
|
||||
}));
|
||||
|
||||
const { handleResumePdfDownload } = await import("./resume-pdf");
|
||||
|
||||
describe("handleResumePdfDownload", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the PDF when the signed URL token is valid", async () => {
|
||||
const pdf = new File([new Uint8Array([37, 80, 68, 70])], "Scizor.pdf", { type: "application/pdf" });
|
||||
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({
|
||||
ok: true,
|
||||
resumeId: "resume-1",
|
||||
userId: "user-1",
|
||||
expiresAt: "2026-06-01T10:10:00.000Z",
|
||||
});
|
||||
mocks.createResumePdfDownload.mockResolvedValueOnce({
|
||||
headers: { "content-disposition": 'attachment; filename="Scizor.pdf"' },
|
||||
body: pdf,
|
||||
});
|
||||
|
||||
const response = await handleResumePdfDownload(
|
||||
new Request("https://example.com/api/resumes/resume-1/pdf?token=signed"),
|
||||
"resume-1",
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("application/pdf");
|
||||
expect(response.headers.get("Content-Disposition")).toBe('attachment; filename="Scizor.pdf"');
|
||||
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
expect(await response.text()).toBe("%PDF");
|
||||
expect(mocks.createResumePdfDownload).toHaveBeenCalledWith({ id: "resume-1", userId: "user-1" });
|
||||
});
|
||||
|
||||
it("rejects missing, invalid, and expired tokens before rendering", async () => {
|
||||
let response = await handleResumePdfDownload(
|
||||
new Request("https://example.com/api/resumes/resume-1/pdf"),
|
||||
"resume-1",
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
|
||||
|
||||
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({ ok: false, reason: "invalid_signature" });
|
||||
response = await handleResumePdfDownload(
|
||||
new Request("https://example.com/api/resumes/resume-1/pdf?token=bad"),
|
||||
"resume-1",
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
|
||||
|
||||
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({ ok: false, reason: "expired" });
|
||||
response = await handleResumePdfDownload(
|
||||
new Request("https://example.com/api/resumes/resume-1/pdf?token=expired"),
|
||||
"resume-1",
|
||||
);
|
||||
expect(response.status).toBe(410);
|
||||
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createResumePdfDownload, verifyResumePdfDownloadToken } from "@reactive-resume/api/features/resume/export";
|
||||
|
||||
function unauthorizedResponse() {
|
||||
return new Response("Unauthorized", {
|
||||
status: 401,
|
||||
headers: {
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function expiredResponse() {
|
||||
return new Response("Download link expired", {
|
||||
status: 410,
|
||||
headers: {
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function errorStatus(error: unknown) {
|
||||
const code = typeof error === "object" && error && "code" in error ? (error as { code?: unknown }).code : undefined;
|
||||
return code === "NOT_FOUND" ? 404 : 500;
|
||||
}
|
||||
|
||||
export async function handleResumePdfDownload(request: Request, id: string) {
|
||||
const token = new URL(request.url).searchParams.get("token");
|
||||
if (!token) return unauthorizedResponse();
|
||||
|
||||
const verification = verifyResumePdfDownloadToken({ resumeId: id, token });
|
||||
if (!verification.ok) return verification.reason === "expired" ? expiredResponse() : unauthorizedResponse();
|
||||
|
||||
try {
|
||||
const download = await createResumePdfDownload({ id, userId: verification.userId });
|
||||
|
||||
return new Response(download.body, {
|
||||
headers: {
|
||||
"Content-Type": download.body.type || "application/pdf",
|
||||
"Content-Disposition": download.headers["content-disposition"],
|
||||
"Cache-Control": "private, no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[PDF Download]", error);
|
||||
return new Response("Failed to generate resume PDF", {
|
||||
status: errorStatus(error),
|
||||
headers: {
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ export async function createMcpServer(request: Request) {
|
||||
`Read schema at \`resume://_meta/schema\`; read resume JSON via \`resume://{id}\` or \`${MCP_TOOL_NAME.getResume}\`.`,
|
||||
`Apply body edits with JSON Patch through \`${MCP_TOOL_NAME.patchResume}\`.`,
|
||||
`Change name, slug, tags, or public visibility with \`${MCP_TOOL_NAME.updateResume}\` (returns canonical share URL; anonymous access only when \`isPublic\` is true; passwords are managed in the web app only).`,
|
||||
`Create short-lived authenticated PDF download URLs with \`${MCP_TOOL_NAME.downloadResumePdf}\`.`,
|
||||
`Import full ResumeData JSON with \`${MCP_TOOL_NAME.importResume}\`; read saved AI analysis with \`${MCP_TOOL_NAME.getResumeAnalysis}\`.`,
|
||||
].join(" "),
|
||||
},
|
||||
|
||||
+27
-27
@@ -16,20 +16,20 @@
|
||||
"lingui:extract": "lingui extract --clean --overwrite"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^3.0.193",
|
||||
"@ai-sdk/react": "^3.0.195",
|
||||
"@base-ui/react": "^1.5.0",
|
||||
"@better-auth/api-key": "^1.6.11",
|
||||
"@better-auth/infra": "^0.2.10",
|
||||
"@better-auth/oauth-provider": "^1.6.11",
|
||||
"@better-auth/passkey": "^1.6.11",
|
||||
"@better-auth/api-key": "^1.6.13",
|
||||
"@better-auth/infra": "^0.2.11",
|
||||
"@better-auth/oauth-provider": "^1.6.13",
|
||||
"@better-auth/passkey": "^1.6.13",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@lingui/core": "^6.1.0",
|
||||
"@lingui/react": "^6.1.0",
|
||||
"@orpc/client": "^1.14.3",
|
||||
"@orpc/server": "^1.14.3",
|
||||
"@orpc/tanstack-query": "^1.14.3",
|
||||
"@orpc/client": "^1.14.4",
|
||||
"@orpc/server": "^1.14.4",
|
||||
"@orpc/tanstack-query": "^1.14.4",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@reactive-resume/ai": "workspace:*",
|
||||
@@ -43,31 +43,31 @@
|
||||
"@reactive-resume/ui": "workspace:*",
|
||||
"@reactive-resume/utils": "workspace:*",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tanstack/react-form": "^1.32.1",
|
||||
"@tanstack/react-form": "^1.33.0",
|
||||
"@tanstack/react-hotkeys": "^0.10.0",
|
||||
"@tanstack/react-query": "^5.100.14",
|
||||
"@tanstack/react-router": "^1.170.8",
|
||||
"@tiptap/extension-color": "^3.23.6",
|
||||
"@tiptap/extension-highlight": "^3.23.6",
|
||||
"@tiptap/extension-table": "^3.23.6",
|
||||
"@tiptap/extension-text-align": "^3.23.6",
|
||||
"@tiptap/extension-text-style": "^3.23.6",
|
||||
"@tiptap/pm": "^3.23.6",
|
||||
"@tiptap/react": "^3.23.6",
|
||||
"@tiptap/starter-kit": "^3.23.6",
|
||||
"@tanstack/react-router": "^1.170.10",
|
||||
"@tiptap/extension-color": "^3.24.0",
|
||||
"@tiptap/extension-highlight": "^3.24.0",
|
||||
"@tiptap/extension-table": "^3.24.0",
|
||||
"@tiptap/extension-text-align": "^3.24.0",
|
||||
"@tiptap/extension-text-style": "^3.24.0",
|
||||
"@tiptap/pm": "^3.24.0",
|
||||
"@tiptap/react": "^3.24.0",
|
||||
"@tiptap/starter-kit": "^3.24.0",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@uiw/color-convert": "^2.10.3",
|
||||
"@uiw/react-color-colorful": "^2.10.3",
|
||||
"ai": "^6.0.191",
|
||||
"better-auth": "1.6.11",
|
||||
"ai": "^6.0.193",
|
||||
"better-auth": "1.6.13",
|
||||
"cmdk": "^1.1.1",
|
||||
"drizzle-orm": "1.0.0-rc.3",
|
||||
"es-toolkit": "^1.47.0",
|
||||
"fuse.js": "^7.3.0",
|
||||
"fuse.js": "^7.4.0",
|
||||
"immer": "^11.1.8",
|
||||
"js-cookie": "^3.0.7",
|
||||
"js-cookie": "^3.0.8",
|
||||
"motion": "^12.40.0",
|
||||
"pdfjs-dist": "5.7.284",
|
||||
"pdfjs-dist": "6.0.227",
|
||||
"pg": "^8.21.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.6",
|
||||
@@ -80,7 +80,7 @@
|
||||
"ts-pattern": "^5.9.0",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.13"
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.7",
|
||||
@@ -90,16 +90,16 @@
|
||||
"@lingui/vite-plugin": "^6.1.0",
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@rolldown/plugin-babel": "^0.2.3",
|
||||
"@tanstack/router-plugin": "^1.168.11",
|
||||
"@tanstack/router-plugin": "^1.168.13",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.2",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"babel-plugin-macros": "^3.1.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.14"
|
||||
"vite": "^8.0.15"
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -13,7 +13,7 @@
|
||||
"type": "git",
|
||||
"url": "https://github.com/amruthpillai/reactive-resume.git"
|
||||
},
|
||||
"packageManager": "pnpm@11.4.0",
|
||||
"packageManager": "pnpm@11.5.0",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*",
|
||||
@@ -39,8 +39,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.16",
|
||||
"@commitlint/cli": "^21.0.1",
|
||||
"@commitlint/config-conventional": "^21.0.1",
|
||||
"@commitlint/cli": "^21.0.2",
|
||||
"@commitlint/config-conventional": "^21.0.2",
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -49,10 +49,10 @@
|
||||
"@types/node": "^25.9.1",
|
||||
"@vitest/coverage-v8": "^4.1.7",
|
||||
"happy-dom": "^20.9.0",
|
||||
"knip": "^6.14.2",
|
||||
"lefthook": "^2.1.8",
|
||||
"knip": "^6.15.0",
|
||||
"lefthook": "^2.1.9",
|
||||
"npm-check-updates": "^22.2.1",
|
||||
"turbo": "^2.9.15",
|
||||
"turbo": "^2.9.16",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.7"
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.2",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -16,17 +16,17 @@
|
||||
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@better-auth/api-key": "^1.6.11",
|
||||
"@better-auth/drizzle-adapter": "^1.6.11",
|
||||
"@better-auth/infra": "^0.2.10",
|
||||
"@better-auth/oauth-provider": "^1.6.11",
|
||||
"@better-auth/passkey": "^1.6.11",
|
||||
"@better-auth/api-key": "^1.6.13",
|
||||
"@better-auth/drizzle-adapter": "^1.6.13",
|
||||
"@better-auth/infra": "^0.2.11",
|
||||
"@better-auth/oauth-provider": "^1.6.13",
|
||||
"@better-auth/passkey": "^1.6.13",
|
||||
"@reactive-resume/db": "workspace:*",
|
||||
"@reactive-resume/email": "workspace:*",
|
||||
"@reactive-resume/env": "workspace:*",
|
||||
"@reactive-resume/utils": "workspace:*",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-auth": "1.6.11",
|
||||
"better-auth": "1.6.13",
|
||||
"drizzle-orm": "1.0.0-rc.3",
|
||||
"jose": "^6.2.3",
|
||||
"react": "^19.2.6",
|
||||
@@ -36,7 +36,7 @@
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/react": "^19.2.15",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.2",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.2",
|
||||
"drizzle-kit": "1.0.0-rc.3",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.2",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@reactive-resume/env": "workspace:*",
|
||||
"nodemailer": "^8.0.9",
|
||||
"nodemailer": "^8.0.10",
|
||||
"react": "^19.2.6",
|
||||
"react-email": "^6.5.0"
|
||||
},
|
||||
@@ -26,7 +26,7 @@
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/react": "^19.2.15",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.2",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -22,7 +22,7 @@
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@types/node": "^25.9.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.2",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.2",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.2",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@orpc/server": "^1.14.3",
|
||||
"@orpc/server": "^1.14.4",
|
||||
"@reactive-resume/ai": "workspace:*",
|
||||
"@reactive-resume/api": "workspace:*",
|
||||
"@reactive-resume/env": "workspace:*",
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.2",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.7"
|
||||
}
|
||||
|
||||
@@ -32,6 +32,15 @@ describe("buildMcpServerCard", () => {
|
||||
expect(cardToolNames).toEqual(expectedNames);
|
||||
});
|
||||
|
||||
it("advertises a short-lived PDF download URL tool", () => {
|
||||
const tool = card.tools.find((item) => item.name === "download_resume_pdf");
|
||||
|
||||
expect(tool?.title).toBe("Download Resume PDF");
|
||||
expect(tool?.description).toContain("short-lived");
|
||||
expect(tool?.description).toContain("10 minutes");
|
||||
expect(tool?.annotations?.readOnlyHint).toBe(true);
|
||||
});
|
||||
|
||||
it("declares a JSON Schema input for every tool", () => {
|
||||
for (const tool of card.tools) {
|
||||
expect(tool.inputSchema, tool.name).toBeDefined();
|
||||
|
||||
@@ -83,6 +83,18 @@ export function buildMcpServerCard(appVersion: string) {
|
||||
inputSchema: toJsonSchemaCompat(z.object({ id: resumeId })),
|
||||
annotations: TOOL_ANNOTATIONS[T.getResumeAnalysis],
|
||||
},
|
||||
{
|
||||
name: T.downloadResumePdf,
|
||||
title: "Download Resume PDF",
|
||||
description: [
|
||||
"Create a short-lived authenticated URL for downloading a resume as a PDF.",
|
||||
"The URL expires in 10 minutes and should be used immediately.",
|
||||
"Returns JSON containing: resumeId, name, downloadUrl, expiresAt, expiresInSeconds, contentType.",
|
||||
`Use \`${T.listResumes}\` first to find valid IDs.`,
|
||||
].join("\n"),
|
||||
inputSchema: toJsonSchemaCompat(z.object({ id: resumeId })),
|
||||
annotations: TOOL_ANNOTATIONS[T.downloadResumePdf],
|
||||
},
|
||||
{
|
||||
name: T.createResume,
|
||||
title: "Create Resume",
|
||||
|
||||
@@ -4,6 +4,7 @@ export const MCP_TOOL_NAME = {
|
||||
listResumeTags: "list_resume_tags",
|
||||
getResume: "read_resume",
|
||||
getResumeAnalysis: "get_resume_analysis",
|
||||
downloadResumePdf: "download_resume_pdf",
|
||||
createResume: "create_resume",
|
||||
importResume: "import_resume",
|
||||
duplicateResume: "duplicate_resume",
|
||||
|
||||
@@ -44,6 +44,13 @@ describe("TOOL_ANNOTATIONS", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("marks PDF download URL generation as read-only but non-idempotent", () => {
|
||||
const annotations = TOOL_ANNOTATIONS[MCP_TOOL_NAME.downloadResumePdf];
|
||||
expect(annotations.readOnlyHint).toBe(true);
|
||||
expect(annotations.idempotentHint).toBe(false);
|
||||
expect(annotations.destructiveHint).toBe(false);
|
||||
});
|
||||
|
||||
it("marks deleteResume as destructive (but still idempotent)", () => {
|
||||
const annotations = TOOL_ANNOTATIONS[MCP_TOOL_NAME.deleteResume];
|
||||
expect(annotations.destructiveHint).toBe(true);
|
||||
|
||||
@@ -29,6 +29,12 @@ export const TOOL_ANNOTATIONS: Record<McpRegisteredToolName, ToolAnnotations> =
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
[MCP_TOOL_NAME.downloadResumePdf]: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
[MCP_TOOL_NAME.createResume]: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// biome-ignore-all lint/style/noNonNullAssertion: These tests assert registered tool names before exercising handlers.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolveUserFromRequestHeaders: vi.fn(),
|
||||
createResumePdfDownloadUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/api/context", () => ({
|
||||
resolveUserFromRequestHeaders: mocks.resolveUserFromRequestHeaders,
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/api/features/resume/export", () => ({
|
||||
PDF_DOWNLOAD_URL_EXPIRES_IN_SECONDS: 600,
|
||||
createResumePdfDownloadUrl: mocks.createResumePdfDownloadUrl,
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/env/server", () => ({
|
||||
env: {
|
||||
APP_URL: "https://example.com",
|
||||
},
|
||||
}));
|
||||
|
||||
const { MCP_TOOL_NAME, registerTools } = await import("./tools");
|
||||
|
||||
type ToolHandler = (input: { id: string }) => Promise<{
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
isError?: boolean;
|
||||
}>;
|
||||
|
||||
type Registration = {
|
||||
name: string;
|
||||
config: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
inputSchema?: unknown;
|
||||
};
|
||||
handler: ToolHandler;
|
||||
};
|
||||
|
||||
const makeFakeServer = () => {
|
||||
const registered: Registration[] = [];
|
||||
const server = {
|
||||
registerTool: vi.fn((name: string, config: Registration["config"], handler: ToolHandler) => {
|
||||
registered.push({ name, config, handler });
|
||||
}),
|
||||
};
|
||||
return { server, registered };
|
||||
};
|
||||
|
||||
const clientMock = {
|
||||
resume: {
|
||||
getById: vi.fn(),
|
||||
list: vi.fn(),
|
||||
tags: { list: vi.fn() },
|
||||
analysis: { getById: vi.fn() },
|
||||
create: vi.fn(),
|
||||
import: vi.fn(),
|
||||
duplicate: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
setLocked: vi.fn(),
|
||||
statistics: { getById: vi.fn() },
|
||||
},
|
||||
};
|
||||
|
||||
describe("registerTools", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("registers a PDF download URL tool that validates access before signing", async () => {
|
||||
clientMock.resume.getById.mockResolvedValueOnce({ id: "resume-1", name: "Scizor" });
|
||||
mocks.resolveUserFromRequestHeaders.mockResolvedValueOnce({ id: "user-1" });
|
||||
mocks.createResumePdfDownloadUrl.mockReturnValueOnce({
|
||||
url: "https://example.com/api/resumes/resume-1/pdf?token=signed",
|
||||
expiresAt: "2026-06-01T10:10:00.000Z",
|
||||
expiresInSeconds: 600,
|
||||
});
|
||||
|
||||
const requestHeaders = new Headers({ "x-api-key": "key" });
|
||||
const { server, registered } = makeFakeServer();
|
||||
registerTools(server as never, clientMock as never, requestHeaders);
|
||||
|
||||
const tool = registered.find((item) => item.name === "download_resume_pdf")!;
|
||||
const result = await tool.handler({ id: "resume-1" });
|
||||
const payload = JSON.parse(result.content[0]!.text);
|
||||
|
||||
expect(tool.config.title).toBe("Download Resume PDF");
|
||||
expect(clientMock.resume.getById).toHaveBeenCalledWith({ id: "resume-1" });
|
||||
expect(mocks.resolveUserFromRequestHeaders).toHaveBeenCalledWith(requestHeaders);
|
||||
expect(mocks.createResumePdfDownloadUrl).toHaveBeenCalledWith({ resumeId: "resume-1", userId: "user-1" });
|
||||
expect(payload).toEqual({
|
||||
resumeId: "resume-1",
|
||||
name: "Scizor",
|
||||
downloadUrl: "https://example.com/api/resumes/resume-1/pdf?token=signed",
|
||||
expiresAt: "2026-06-01T10:10:00.000Z",
|
||||
expiresInSeconds: 600,
|
||||
contentType: "application/pdf",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the tool name stable", () => {
|
||||
expect(MCP_TOOL_NAME.downloadResumePdf).toBe("download_resume_pdf");
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,10 @@ import type router from "@reactive-resume/api/routers";
|
||||
import z from "zod";
|
||||
import { resumePatchOperationsSchema } from "@reactive-resume/ai/tools/resume-tool-contracts";
|
||||
import { resolveUserFromRequestHeaders } from "@reactive-resume/api/context";
|
||||
import {
|
||||
createResumePdfDownloadUrl,
|
||||
PDF_DOWNLOAD_URL_EXPIRES_IN_SECONDS,
|
||||
} from "@reactive-resume/api/features/resume/export";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { MCP_TOOL_NAME } from "./mcp-tool-names";
|
||||
@@ -195,6 +199,44 @@ export function registerTools(server: McpServer, client: RouterClient<typeof rou
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Download Resume PDF ───────────────────────────────────────
|
||||
server.registerTool(
|
||||
T.downloadResumePdf,
|
||||
{
|
||||
title: "Download Resume PDF",
|
||||
description: [
|
||||
"Create a short-lived authenticated URL for downloading a resume as a PDF.",
|
||||
`The URL expires in ${PDF_DOWNLOAD_URL_EXPIRES_IN_SECONDS / 60} minutes and should be used immediately.`,
|
||||
"Returns JSON containing: resumeId, name, downloadUrl, expiresAt, expiresInSeconds, contentType.",
|
||||
`Use \`${T.listResumes}\` first to find valid IDs.`,
|
||||
].join("\n"),
|
||||
inputSchema: z.object({ id: resumeIdSchema }),
|
||||
annotations: TOOL_ANNOTATIONS[T.downloadResumePdf],
|
||||
},
|
||||
withErrorHandling("creating PDF download URL", async ({ id }: { id: string }) => {
|
||||
const resume = await client.resume.getById({ id });
|
||||
const user = await resolveUserFromRequestHeaders(requestHeaders);
|
||||
if (!user) throw new Error("Unauthorized");
|
||||
|
||||
const signedUrl = createResumePdfDownloadUrl({ resumeId: id, userId: user.id });
|
||||
|
||||
return text(
|
||||
JSON.stringify(
|
||||
{
|
||||
resumeId: id,
|
||||
name: resume.name,
|
||||
downloadUrl: signedUrl.url,
|
||||
expiresAt: signedUrl.expiresAt,
|
||||
expiresInSeconds: signedUrl.expiresInSeconds,
|
||||
contentType: "application/pdf",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Create Resume ─────────────────────────────────────────────
|
||||
server.registerTool(
|
||||
T.createResume,
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"@react-pdf/types": "^2.11.1",
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@types/react": "^19.2.15",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.2",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.2",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.2",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,12 +25,12 @@
|
||||
"@reactive-resume/utils": "workspace:*",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"js-cookie": "^3.0.7",
|
||||
"js-cookie": "^3.0.8",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-resizable-panels": "^4.11.2",
|
||||
"shadcn": "^4.8.2",
|
||||
"shadcn": "^4.9.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
@@ -41,7 +41,7 @@
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.2",
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^6.0.3"
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@types/node": "^25.9.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.2",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1290
-1498
File diff suppressed because it is too large
Load Diff
@@ -12,9 +12,9 @@
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@reactive-resume/env": "workspace:*",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.2",
|
||||
"drizzle-orm": "1.0.0-rc.3",
|
||||
"pg": "^8.21.0",
|
||||
"tsx": "^4.22.3"
|
||||
"tsx": "^4.22.4"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user