feat(mcp): add cover-letter PDF downloads (#3304)

* feat(mcp): add cover-letter PDF downloads

* fix(mcp): bind signed PDF targets

* test(mcp): cover unavailable cover letters

* fix(api): accept legacy PDF download targets

* fix(server): limit legacy PDF tokens to resumes

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
This commit is contained in:
ignaciocarre
2026-08-13 23:09:56 +02:00
committed by GitHub
co-authored by Amruth Pillai
parent 45303fb465
commit 6d9ebccc63
12 changed files with 210 additions and 29 deletions
@@ -1,3 +1,4 @@
import { createHmac } from "node:crypto";
import { describe, expect, it, vi } from "vitest";
vi.mock("@reactive-resume/env/server", () => ({
@@ -43,11 +44,12 @@ describe("resume PDF signed download URLs", () => {
ok: true,
resumeId: "resume-1",
userId: "user-1",
target: "resume",
expiresAt: "2026-06-01T10:10:00.000Z",
});
});
it("can include the cover letter target without changing token verification", () => {
it("binds the cover letter target to the signed token", () => {
const result = createResumePdfDownloadUrl({
resumeId: "resume-1",
userId: "user-1",
@@ -65,7 +67,30 @@ describe("resume PDF signed download URLs", () => {
token,
now: new Date("2026-06-01T10:01:00.000Z"),
}),
).toMatchObject({ ok: true });
).toMatchObject({ ok: true, target: "cover-letter" });
});
it("accepts still-valid legacy tokens without a target", () => {
const payload = Buffer.from(
JSON.stringify({
v: 1,
resumeId: "resume-1",
userId: "user-1",
expiresAt: new Date("2026-06-01T10:10:00.000Z").getTime(),
issuedAt: new Date("2026-06-01T10:00:00.000Z").getTime(),
}),
"utf8",
).toString("base64url");
const token = `${payload}.${createHmac("sha256", "test-secret").update(payload).digest("base64url")}`;
const verification = verifyResumePdfDownloadToken({
resumeId: "resume-1",
token,
now: new Date("2026-06-01T10:01:00.000Z"),
});
expect(verification).toMatchObject({ ok: true });
expect("target" in verification).toBe(false);
});
it("rejects expired, tampered, and mismatched tokens", () => {
@@ -8,6 +8,7 @@ type PdfDownloadTokenPayload = {
v: 1;
resumeId: string;
userId: string;
target?: ResumeExportTarget;
expiresAt: number;
issuedAt: number;
};
@@ -31,6 +32,7 @@ type VerifyResumePdfDownloadTokenResult =
ok: true;
resumeId: string;
userId: string;
target?: ResumeExportTarget;
expiresAt: string;
}
| {
@@ -69,6 +71,7 @@ function parsePayload(value: unknown): PdfDownloadTokenPayload | null {
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 (payload.target !== undefined && payload.target !== "resume" && payload.target !== "cover-letter") return null;
if (typeof payload.expiresAt !== "number" || !Number.isFinite(payload.expiresAt)) return null;
if (typeof payload.issuedAt !== "number" || !Number.isFinite(payload.issuedAt)) return null;
@@ -88,6 +91,7 @@ export function createResumePdfDownloadUrl({
v: 1,
resumeId,
userId,
target: target ?? "resume",
expiresAt: expiresAt.getTime(),
issuedAt: now.getTime(),
} satisfies PdfDownloadTokenPayload);
@@ -122,6 +126,7 @@ export function verifyResumePdfDownloadToken({
ok: true,
resumeId: parsed.resumeId,
userId: parsed.userId,
...(parsed.target ? { target: parsed.target } : {}),
expiresAt: new Date(parsed.expiresAt).toISOString(),
};
} catch {