diff --git a/docs/operations/runbooks/resume-mcp.md b/docs/operations/runbooks/resume-mcp.md index 134ef77..a3eeeb0 100644 --- a/docs/operations/runbooks/resume-mcp.md +++ b/docs/operations/runbooks/resume-mcp.md @@ -7,7 +7,7 @@ title: Resume MCP setup and verification Return a tailored draft from an assistant using a base resume plus a job URL, qualified ID or pasted description. The result includes complete Markdown and change explanations; request `format: "html"` for an additional portable HTML -document. MCP drafts are unsaved by default; request save_to_history explicitly to save a signed-in version. No application is submitted. +document. Request `format: "pdf"` for a PDF attachment. MCP drafts are unsaved by default; request save_to_history explicitly to save a signed-in version. No application is submitted. Architecture and limits: [ADR-0007](../../architecture/decisions/0007-portable-resume-mcp.md). Fixed-wording tailoring: [ADR-0008](../../architecture/decisions/0008-fixed-wording-resume-ranking.md). @@ -123,7 +123,8 @@ above and one owner-approved tool call. Actual ChatGPT connection and signed-in - `rolepatch_resume_profiles`: list owned cloud profile IDs/names when signed in. - `rolepatch_resolve_job`: normalize a URL or board-qualified ID without fetching it. -- `rolepatch_tailor_resume`: prepare a reviewed, unsaved draft. +- `rolepatch_tailor_resume`: prepare a reviewed, unsaved draft; request `format: "pdf"` when the user asks for a file. +- `rolepatch_render_resume_pdf`: render supplied draft Markdown without generation credits or persistence. Example tool arguments (synthetic data): @@ -285,3 +286,15 @@ A charged AI credit is refunded; shared budget denials remain errors. Text matching can miss synonyms or implicit requirements, and the summary can be less fluent. Sparse evidence can still fail rather than inventing claims. See [ADR-0014](../../architecture/decisions/0014-source-preserving-provider-fallback.md). + +## PDF delivery and export retries + +After account/master setup, request `{ "job_url": "https://example.com/jobs/engineer", "format": "pdf" }`; select `resume_id` if multiple masters exist. Guests supply `resume_markdown` each time. Markdown defaults and HTML remain unchanged. + +The JSON API includes `pdf: { filename, mime_type, data_base64 }`: decode the base64 bytes and save the named file. MCP returns an embedded `resource` with `mimeType: "application/pdf"` and `blob` (base64), plus review/change metadata in text and structured content. The opaque URI identifies an inline attachment, not a stored/fetchable download. PDFs are bounded to 2 MiB and private/no-store. + +Review every draft (`requires_review: true`). Rendering precedes optional History saving. Export failure returns `ok: false`, `code: "pdf_render_failed"` (plain API HTTP 502), recoverable `markdown`/`changes`, draft/review metadata and `persisted: false`; no History is saved. Successful AI generation still uses its existing one credit; source fallback already refunds. Do not repeat tailoring just to obtain the PDF. + +Retry `rolepatch_render_resume_pdf` with the returned Markdown as `resume_markdown`, or POST `{ "operation": "render_pdf", "resume_markdown": "" }` to `/api/resume`. + +Rendering uses no AI credits, profile lookup or persistence. Supply text, not a saved draft ID; up to 40,000 characters accommodates summaries and saved material. Extra job/resume-ID/save fields are rejected. Existing credentials still pass scope/revocation checks; MCP-audience tokens remain MCP-only. diff --git a/knip.json b/knip.json index b526aed..8ba91aa 100644 --- a/knip.json +++ b/knip.json @@ -31,6 +31,7 @@ "puppeteer-core" ], "workspaces": { + ".": { "entry": ["src/__tests__/fixtures/render-pdf.mjs"] }, "extension": { "entry": ["src/background.ts", "src/content.ts", "src/popup.ts", "scripts/*.mjs"], "project": ["src/**/*.ts", "scripts/*.mjs"] diff --git a/src/__tests__/fixtures/render-pdf.mjs b/src/__tests__/fixtures/render-pdf.mjs new file mode 100644 index 0000000..1c5caae --- /dev/null +++ b/src/__tests__/fixtures/render-pdf.mjs @@ -0,0 +1,21 @@ +// Native bridge for the opt-in local PDF test; invokes the actual shared renderer. +import { registerHooks } from 'node:module'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { join } from 'node:path'; + +const root = fileURLToPath(new URL('../../../', import.meta.url)); +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier.startsWith('@/')) + return nextResolve( + pathToFileURL(join(root, 'src', `${specifier.slice(2)}.ts`)).href, + context + ); + return nextResolve(specifier, context); + }, +}); +const { renderPdf } = await import(pathToFileURL(join(root, 'src/lib/pdf.ts')).href); +const chunks = []; +for await (const chunk of process.stdin) chunks.push(chunk); +const bytes = await renderPdf(Buffer.concat(chunks).toString()); +process.stdout.write(Buffer.from(bytes).toString('base64')); diff --git a/src/__tests__/resume-access.test.ts b/src/__tests__/resume-access.test.ts index 08cff28..5784149 100644 --- a/src/__tests__/resume-access.test.ts +++ b/src/__tests__/resume-access.test.ts @@ -12,6 +12,10 @@ import { db } from '@/lib/db'; import { getResumeApiUserId, issueResumeAccess, withResumeAccess } from '@/lib/resume-access'; import { tailorResumeFromReference } from '@/lib/actions/resume-assistant-actions'; import { listResumeHistory } from '@/lib/actions/resume-history-actions'; +import { renderPdf } from '@/lib/pdf'; +import { renderResumePdfDraft } from '@/lib/resume-pdf-draft'; +import { POST as resumePost } from '@/app/api/resume/route'; +import { POST as mcpPost } from '@/app/api/mcp/route'; interface TestDatabase { exec(sql: string): void; @@ -37,6 +41,7 @@ vi.mock('@/lib/db', () => ({ db: { execute: vi.fn(), batch: vi.fn() } })); vi.mock('@/lib/ping', () => ({ ping: vi.fn().mockResolvedValue(false) })); vi.mock('@/lib/analytics', () => ({ trackActivated: vi.fn(), trackCoreAction: vi.fn() })); vi.mock('ai', () => ({ generateObject: state.generate })); +vi.mock('@/lib/pdf', () => ({ renderPdf: vi.fn() })); vi.mock('@/lib/ai-cloudflare', () => ({ getAIModel: () => ({}), getAIModelRetryOptions: () => ({}), @@ -114,6 +119,9 @@ beforeEach(() => { state.generate.mockRejectedValue( Object.assign(new Error('synthetic unavailable provider'), { statusCode: 502 }) ); + vi.mocked(renderPdf).mockResolvedValue( + new TextEncoder().encode('%PDF-1.7\nsynthetic fixture\n%%EOF') + ); vi.spyOn(console, 'error').mockImplementation(() => {}); }); afterEach(() => { @@ -180,10 +188,13 @@ it('rejects foreign master before generation, billing or saving', async () => { if (!access.ok) throw new Error('Expected local session'); state.requestHeaders = new Headers(); const response = await withResumeAccess(request(access.token), async () => - Response.json(await tailorResumeFromReference({ resume_id: 'foreign', jd_text: jd }, true)) + Response.json( + await tailorResumeFromReference({ resume_id: 'foreign', jd_text: jd, format: 'pdf' }, true) + ) ); expect(await response.json()).toMatchObject({ ok: false, code: 'not_found' }); expect(state.generate).not.toHaveBeenCalled(); + expect(renderPdf).not.toHaveBeenCalled(); expect(sql.prepare('SELECT COUNT(*) AS n FROM tailored_resumes').get()?.n).toBe(0); }); @@ -249,3 +260,157 @@ it('omits fallback_reason on an AI-generated API response', async () => { expect(output).toMatchObject({ ok: true, generation_method: 'ai' }); expect(output).not.toHaveProperty('fallback_reason'); }); + +it('keeps a successful AI charge after export failure and renders the recoverable draft without a second charge', async () => { + state.generate.mockImplementation(async ({ prompt }: { prompt: string }) => { + const input = JSON.parse(prompt); + return { + object: { + summary: { + text: 'Built React screens and tested keyboard navigation. Built scheduling APIs with Node.js request validation.', + evidence_ids: ['f1', 'f2'], + }, + ...input.response_template, + }, + }; + }); + const access = await createResumeApiAccess(); + if (!access.ok) throw new Error('Expected local session'); + state.requestHeaders = new Headers(); + vi.mocked(renderPdf).mockRejectedValueOnce(new Error('synthetic PDF unavailable')); + const response = await withResumeAccess(request(access.token), async () => + Response.json( + await tailorResumeFromReference({ + resume_id: 'master', + jd_text: jd, + format: 'pdf', + save_to_history: true, + }) + ) + ); + const failed = await response.json(); + expect(failed).toMatchObject({ + ok: false, + code: 'pdf_render_failed', + generation_method: 'ai', + persisted: false, + requires_review: true, + }); + expect(failed.markdown).toContain('- Built React screens and tested keyboard navigation.'); + expect(sql.prepare('SELECT source FROM resumes WHERE id=?').get('master')?.source).toBe(master); + expect(sql.prepare('SELECT COUNT(*) AS n FROM tailored_resumes').get()?.n).toBe(0); + expect( + sql.prepare('SELECT balance FROM token_balances WHERE user_id=?').get('owner')?.balance + ).toBe(2); + expect( + sql.prepare('SELECT COUNT(*) AS n FROM token_transactions WHERE user_id=?').get('owner')?.n + ).toBe(1); + const retry = await withResumeAccess(request(access.token), async () => + Response.json(await renderResumePdfDraft({ resume_markdown: failed.markdown })) + ); + expect(await retry.json()).toMatchObject({ + ok: true, + persisted: false, + pdf: { mime_type: 'application/pdf' }, + }); + expect(state.generate).toHaveBeenCalledTimes(1); + expect( + sql.prepare('SELECT COUNT(*) AS n FROM token_transactions WHERE user_id=?').get('owner')?.n + ).toBe(1); +}); + +it('exports source fallback PDF with exactly one refund and no stored guest or implicit MCP history', async () => { + const access = await createResumeApiAccess(); + if (!access.ok) throw new Error('Expected local session'); + state.requestHeaders = new Headers(); + const response = await withResumeAccess(request(access.token, '/api/mcp'), async () => + Response.json( + await tailorResumeFromReference({ resume_id: 'master', jd_text: jd, format: 'pdf' }) + ) + ); + expect(await response.json()).toMatchObject({ + ok: true, + generation_method: 'source_fallback', + fallback_reason: expect.any(String), + persisted: false, + pdf: { mime_type: 'application/pdf' }, + }); + expect( + sql.prepare('SELECT balance FROM token_balances WHERE user_id=?').get('owner')?.balance + ).toBe(3); + expect( + sql.prepare('SELECT COUNT(*) AS n FROM token_transactions WHERE user_id=?').get('owner')?.n + ).toBe(2); + expect(sql.prepare('SELECT COUNT(*) AS n FROM tailored_resumes').get()?.n).toBe(0); +}); + +it('retains fallback diagnostics and the already refunded draft after PDF failure', async () => { + const access = await createResumeApiAccess(); + if (!access.ok) throw new Error('Expected local session'); + state.requestHeaders = new Headers(); + vi.mocked(renderPdf).mockRejectedValueOnce(new Error('synthetic PDF unavailable')); + const response = await withResumeAccess(request(access.token), async () => + Response.json( + await tailorResumeFromReference({ resume_id: 'master', jd_text: jd, format: 'pdf' }, true) + ) + ); + expect(await response.json()).toMatchObject({ + ok: false, + code: 'pdf_render_failed', + generation_method: 'source_fallback', + fallback_reason: expect.any(String), + persisted: false, + markdown: expect.stringContaining('Ada Example'), + }); + expect( + sql.prepare('SELECT balance FROM token_balances WHERE user_id=?').get('owner')?.balance + ).toBe(3); + expect( + sql.prepare('SELECT COUNT(*) AS n FROM token_transactions WHERE user_id=?').get('owner')?.n + ).toBe(2); + expect(sql.prepare('SELECT COUNT(*) AS n FROM tailored_resumes').get()?.n).toBe(0); +}); + +it('rejects revoked PDF retries at both HTTP facades before rendering or touching profiles', async () => { + const access = await createResumeApiAccess(); + if (!access.ok) throw new Error('Expected local session'); + await disconnectResumeApiAccess(); + state.requestHeaders = new Headers(); + vi.mocked(db.execute).mockClear(); + const headers = { + authorization: `Bearer ${access.token}`, + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'cf-connecting-ip': 'revoked-pdf', + }; + const rest = await resumePost( + new Request('https://rolepatch.com/api/resume', { + method: 'POST', + headers, + body: JSON.stringify({ operation: 'render_pdf', resume_markdown: master }), + }) + ); + const mcp = await mcpPost( + new Request('https://rolepatch.com/api/mcp', { + method: 'POST', + headers, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 'retry', + method: 'tools/call', + params: { name: 'rolepatch_render_resume_pdf', arguments: { resume_markdown: master } }, + }), + }) + ); + expect(rest.status).toBe(401); + expect(mcp.status).toBe(401); + expect(renderPdf).not.toHaveBeenCalled(); + expect(state.generate).not.toHaveBeenCalled(); + expect( + vi + .mocked(db.execute) + .mock.calls.every(([input]) => + (typeof input === 'string' ? input : input.sql).includes('FROM "session"') + ) + ).toBe(true); +}); diff --git a/src/__tests__/resume-api-route.test.ts b/src/__tests__/resume-api-route.test.ts index 56b8a00..c8d95c4 100644 --- a/src/__tests__/resume-api-route.test.ts +++ b/src/__tests__/resume-api-route.test.ts @@ -1,7 +1,9 @@ // @vitest-environment node import { beforeEach, describe, expect, it, vi } from 'vitest'; import { tailorResumeFromReference } from '@/lib/actions/resume-assistant-actions'; +import { renderResumePdfDraft } from '@/lib/resume-pdf-draft'; vi.mock('@/lib/actions/resume-assistant-actions', () => ({ tailorResumeFromReference: vi.fn() })); +vi.mock('@/lib/resume-pdf-draft', () => ({ renderResumePdfDraft: vi.fn() })); const request = (value: unknown, headers?: Record) => new Request('https://rolepatch.com/api/resume', { method: 'POST', @@ -40,6 +42,7 @@ describe('plain JSON resume API', () => { ['sign_in_required', 401], ['not_found', 404], ['generation_failed', 502], + ['pdf_render_failed', 502], ['invalid_input', 400], ])('returns HTTP status for %s', async (code, status) => { vi.mocked(tailorResumeFromReference).mockResolvedValue({ @@ -50,6 +53,30 @@ describe('plain JSON resume API', () => { const { POST } = await import('@/app/api/resume/route'); expect((await POST(request({}))).status).toBe(status); }); + it('returns render-only PDF JSON with private no-store metadata without generation', async () => { + const result = { + ok: true, + draft_id: 'draft', + requires_review: true, + persisted: false, + markdown: '# Ada Example resume text', + pdf: { + filename: 'tailored-resume.pdf', + mime_type: 'application/pdf', + data_base64: 'JVBERi0xLjcK', + }, + }; + vi.mocked(renderResumePdfDraft).mockResolvedValue( + result as Awaited> + ); + const { POST } = await import('@/app/api/resume/route'); + const input = { operation: 'render_pdf', resume_markdown: result.markdown }; + const response = await POST(request(input)); + expect(await response.json()).toEqual(result); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + expect(renderResumePdfDraft).toHaveBeenCalledWith({ resume_markdown: result.markdown }); + expect(tailorResumeFromReference).not.toHaveBeenCalled(); + }); it('rejects cross-origin browser mutations before generation', async () => { const { POST } = await import('@/app/api/resume/route'); expect((await POST(request({}, { origin: 'https://other.example' }))).status).toBe(403); diff --git a/src/__tests__/resume-assistant.test.ts b/src/__tests__/resume-assistant.test.ts index a826ca3..1ca2bea 100644 --- a/src/__tests__/resume-assistant.test.ts +++ b/src/__tests__/resume-assistant.test.ts @@ -12,6 +12,7 @@ import { tailorResumeForClient } from '@/lib/actions/tailor-action'; import { recordResumeHistory } from '@/lib/actions/resume-history-actions'; import { extractPublicJob } from '@/lib/public-job-extraction'; import { tailorResumeFromReference } from '@/lib/actions/resume-assistant-actions'; +import { renderPdf } from '@/lib/pdf'; vi.mock('@/lib/auth-utils', () => ({ getCurrentUserId: vi.fn() })); vi.mock('@/lib/actions/job-actions', () => ({ getJobApplication: vi.fn() })); @@ -19,6 +20,7 @@ vi.mock('@/lib/actions/resume-actions', () => ({ getResume: vi.fn(), listResumes vi.mock('@/lib/actions/tailor-action', () => ({ tailorResumeForClient: vi.fn() })); vi.mock('@/lib/actions/resume-history-actions', () => ({ recordResumeHistory: vi.fn() })); vi.mock('@/lib/public-job-extraction', () => ({ extractPublicJob: vi.fn() })); +vi.mock('@/lib/pdf', () => ({ renderPdf: vi.fn() })); const resume = '# Alex Morgan\n\n## Experience\nBuilt TypeScript services and reduced incidents 34%.\n'; const jd = @@ -73,6 +75,76 @@ describe('portable resume actions', () => { data: { tailored: resume, changes: [] }, }); vi.mocked(extractPublicJob).mockResolvedValue(jd); + vi.mocked(renderPdf).mockResolvedValue( + new TextEncoder().encode('%PDF-1.7\nfixture resume\n%%EOF') + ); + }); + it('turns a job link and single owned master into a reviewed PDF without modifying or saving the master', async () => { + vi.mocked(getCurrentUserId).mockResolvedValue('owner'); + vi.mocked(listResumes).mockResolvedValue([ + { id: 'master', name: 'Master', source: resume }, + ] as Awaited>); + const output = await tailorResumeFromReference({ + job_url: 'https://example.com/jobs/engineer', + format: 'pdf', + }); + expect(output).toMatchObject({ + ok: true, + markdown: resume, + requires_review: true, + persisted: false, + pdf: { mime_type: 'application/pdf' }, + }); + expect(extractPublicJob).toHaveBeenCalledWith('https://example.com/jobs/engineer'); + expect(tailorResumeForClient).toHaveBeenCalledTimes(1); + expect(recordResumeHistory).not.toHaveBeenCalled(); + expect(renderPdf).toHaveBeenCalledWith(expect.stringContaining('Alex Morgan')); + }); + it('exports guest PDF statelessly and retains original tailoring arguments', async () => { + expect( + await tailorResumeFromReference({ resume_markdown: resume, jd_text: jd, format: 'pdf' }, true) + ).toMatchObject({ + ok: true, + persisted: false, + requires_review: true, + pdf: { mime_type: 'application/pdf' }, + }); + expect(listResumes).not.toHaveBeenCalled(); + expect(getResume).not.toHaveBeenCalled(); + expect(recordResumeHistory).not.toHaveBeenCalled(); + expect(tailorResumeForClient).toHaveBeenCalledWith( + resume.trim(), + jd.trim(), + expect.any(Object), + '' + ); + }); + it('preserves generated Markdown and skips history when PDF rendering fails', async () => { + vi.mocked(getCurrentUserId).mockResolvedValue('owner'); + vi.mocked(getResume).mockResolvedValue({ + id: 'master', + name: 'Master', + source: resume, + } as Awaited>); + vi.mocked(renderPdf).mockRejectedValue(new Error('internal renderer failure')); + const output = await tailorResumeFromReference({ + resume_id: 'master', + jd_text: jd, + format: 'pdf', + save_to_history: true, + }); + expect(output).toMatchObject({ + ok: false, + code: 'pdf_render_failed', + markdown: resume, + changes: [], + persisted: false, + history: { saved: false }, + generation_method: 'ai', + }); + expect(output).toMatchObject({ error: expect.stringContaining('do not repeat tailoring') }); + expect(recordResumeHistory).not.toHaveBeenCalled(); + expect(tailorResumeForClient).toHaveBeenCalledTimes(1); }); it('returns an unsaved guest draft without reading cloud profiles', async () => { const output = await tailorResumeFromReference({ @@ -233,6 +305,27 @@ describe('optional persisted API/MCP history', () => { }) ); }); + it('saves optional owned history only after successful PDF rendering', async () => { + vi.mocked(renderPdf).mockResolvedValue( + new TextEncoder().encode('%PDF-1.7\nfixture resume\n%%EOF') + ); + const output = await tailorResumeFromReference( + { resume_id: 'master', jd_text: jd, format: 'pdf' }, + true + ); + expect(output).toMatchObject({ + ok: true, + persisted: true, + draft_id: 'version', + pdf: { mime_type: 'application/pdf' }, + }); + expect(vi.mocked(renderPdf).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(recordResumeHistory).mock.invocationCallOrder[0] + ); + expect(recordResumeHistory).toHaveBeenCalledWith( + expect.objectContaining({ source: resume, resume_id: 'master' }) + ); + }); it('keeps default MCP drafts unsaved and respects explicit opt-out', async () => { expect(await tailorResumeFromReference({ resume_id: 'master', jd_text: jd })).toMatchObject({ persisted: false, diff --git a/src/__tests__/resume-mcp-route.test.ts b/src/__tests__/resume-mcp-route.test.ts index 158aaf3..99d57c8 100644 --- a/src/__tests__/resume-mcp-route.test.ts +++ b/src/__tests__/resume-mcp-route.test.ts @@ -3,7 +3,10 @@ import { beforeEach, expect, it, vi } from 'vitest'; import { POST, GET } from '@/app/api/mcp/route'; import { callResumeMcpTool } from '@/lib/resume-mcp'; vi.mock('@/lib/resume-mcp', () => ({ - RESUME_MCP_TOOLS: [{ name: 'rolepatch_tailor_resume', inputSchema: { type: 'object' } }], + RESUME_MCP_TOOLS: [ + { name: 'rolepatch_tailor_resume', inputSchema: { type: 'object' } }, + { name: 'rolepatch_render_resume_pdf', inputSchema: { type: 'object' } }, + ], callResumeMcpTool: vi.fn(), })); const request = (body: unknown, headers: Record = {}) => @@ -35,7 +38,11 @@ it('negotiates the protocol and lists tools', async () => { }); expect( await (await POST(request({ jsonrpc: '2.0', id: 2, method: 'tools/list' }))).json() - ).toMatchObject({ result: { tools: [{ name: 'rolepatch_tailor_resume' }] } }); + ).toMatchObject({ + result: { + tools: [{ name: 'rolepatch_tailor_resume' }, { name: 'rolepatch_render_resume_pdf' }], + }, + }); expect(GET().status).toBe(405); }); it('does not generate on a notification', async () => { @@ -99,6 +106,52 @@ it('returns structured portable drafts and tool errors', async () => { result: { isError: true, structuredContent: { ok: false, error: 'Paste jd_text' } }, }); }); +it('delivers a PDF as an embedded resource without duplicating its blob in metadata or text', async () => { + vi.mocked(callResumeMcpTool).mockResolvedValue({ + ok: true, + draft_id: 'opaque-draft', + requires_review: true, + persisted: false, + markdown: '# Ada Example resume', + pdf: { + filename: 'tailored-resume.pdf', + mime_type: 'application/pdf', + data_base64: 'JVBERi0xLjcK', + }, + } as Awaited>); + const body = await ( + await POST( + request( + { + jsonrpc: '2.0', + id: 'pdf', + method: 'tools/call', + params: { + name: 'rolepatch_render_resume_pdf', + arguments: { resume_markdown: '# Ada Example resume' }, + }, + }, + { 'cf-connecting-ip': 'pdf-resource' } + ) + ) + ).json(); + expect(body.result.content[1]).toEqual({ + type: 'resource', + resource: { + uri: 'rolepatch://drafts/opaque-draft/resume.pdf', + mimeType: 'application/pdf', + blob: 'JVBERi0xLjcK', + }, + }); + expect(body.result.structuredContent.pdf).toEqual({ + filename: 'tailored-resume.pdf', + mime_type: 'application/pdf', + uri: 'rolepatch://drafts/opaque-draft/resume.pdf', + }); + expect(JSON.parse(body.result.content[0].text)).toEqual(body.result.structuredContent); + expect(body.result.content[0].text).not.toContain('JVBER'); + expect(body.result.isError).toBe(false); +}); it('rejects unknown tools and limits repeated generation', async () => { expect( await ( diff --git a/src/__tests__/resume-mcp-stdio.test.ts b/src/__tests__/resume-mcp-stdio.test.ts new file mode 100644 index 0000000..c6f4dfe --- /dev/null +++ b/src/__tests__/resume-mcp-stdio.test.ts @@ -0,0 +1,69 @@ +// @vitest-environment node +import { spawn } from 'node:child_process'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import { expect, it } from 'vitest'; + +it('forwards embedded PDF resources unchanged through the local stdio bridge', async () => { + const result = { + jsonrpc: '2.0', + id: 'pdf', + result: { + isError: false, + structuredContent: { + ok: true, + requires_review: true, + pdf: { + filename: 'tailored-resume.pdf', + mime_type: 'application/pdf', + uri: 'rolepatch://drafts/fixture/resume.pdf', + }, + }, + content: [ + { + type: 'resource', + resource: { + uri: 'rolepatch://drafts/fixture/resume.pdf', + mimeType: 'application/pdf', + blob: 'JVBERi0xLjcK', + }, + }, + ], + }, + }; + let received: unknown; + const server = createServer(async (request, response) => { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + received = JSON.parse(Buffer.concat(chunks).toString()); + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify(result)); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Missing fixture address'); + const child = spawn(process.execPath, ['scripts/resume-mcp.mjs'], { + env: { NODE_ENV: 'test', ROLEPATCH_BASE_URL: `http://127.0.0.1:${address.port}` }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const message = { + jsonrpc: '2.0', + id: 'pdf', + method: 'tools/call', + params: { + name: 'rolepatch_render_resume_pdf', + arguments: { resume_markdown: '# Ada Example resume fixture' }, + }, + }; + try { + child.stdin.end(`${JSON.stringify(message)}\n`); + let output = ''; + for await (const chunk of child.stdout) output += chunk.toString(); + expect(JSON.parse(output.trim())).toEqual(result); + expect(received).toEqual(message); + } finally { + child.kill(); + server.close(); + } +}); diff --git a/src/__tests__/resume-pdf-local.test.ts b/src/__tests__/resume-pdf-local.test.ts new file mode 100644 index 0000000..8c5f069 --- /dev/null +++ b/src/__tests__/resume-pdf-local.test.ts @@ -0,0 +1,137 @@ +// @vitest-environment node +import { spawn } from 'node:child_process'; +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { expect, it, vi } from 'vitest'; +import { PDFParse } from 'pdf-parse'; +import { POST } from '@/app/api/resume/route'; + +const fixture = vi.hoisted(() => ({ + master: + '# Ada Example\nada@example.org\n\n## Experience\n### Example Co\n- Built scheduling APIs with Node.js request validation.\n- Built React screens and tested keyboard navigation.\n\n## Projects\n### Reader\n- Released an accessible reading queue with saved filters.\n\n## Education\nBSc Computer Science, Example University, 2022.\n', + jd: 'Seeking a TypeScript platform engineer to build scheduling APIs with request validation and accessible customer screens. Maintain production services and testing.', +})); +vi.mock('@/lib/auth-utils', () => ({ getCurrentUserId: async () => 'synthetic-owner' })); +vi.mock('@/lib/actions/job-actions', () => ({ getJobApplication: vi.fn() })); +vi.mock('@/lib/actions/resume-actions', () => ({ + getResume: vi.fn(), + listResumes: async () => [{ id: 'synthetic-master', name: 'Master', source: fixture.master }], +})); +vi.mock('@/lib/actions/resume-history-actions', () => ({ recordResumeHistory: vi.fn() })); +vi.mock('@/lib/public-job-extraction', () => ({ extractPublicJob: async () => fixture.jd })); +vi.mock('@/lib/actions/tailor-action', () => ({ + tailorResumeForClient: async (master: string) => ({ + success: true, + data: { + tailored: master.replace( + '## Experience', + '## Summary\nSoftware engineer building Node.js scheduling APIs and React screens. Brings hands-on experience testing keyboard navigation and releasing an accessible reading queue.\n\n## Experience' + ), + changes: [{ snippet: 'Summary', reason: 'Synthetic grounded summary fixture.' }], + }, + }), +})); + +// Opt-in acceptance probe uses existing local Chrome; never a paid browser binding. +it('returns a real readable PDF from job link plus owned master through the API', { + skip: !process.env.PUPPETEER_EXECUTABLE_PATH || !process.env.ROLEPATCH_PDF_EVIDENCE_DIR, + timeout: 30_000, +}, async () => { + const pdfRenderer = await import('@/lib/pdf'); + // Vitest's VM cannot resolve the renderer's Function-based dynamic imports. + // Execute the unchanged renderer in native Node with the same HTML and Chrome. + const renderSpy = vi.spyOn(pdfRenderer, 'renderPdf').mockImplementation(async (html) => { + const child = spawn( + process.execPath, + [join(process.cwd(), 'src/__tests__/fixtures/render-pdf.mjs')], + { + stdio: ['pipe', 'pipe', 'pipe'], + } + ); + const output: Buffer[] = []; + const errors: Buffer[] = []; + child.stdout.on('data', (data: Buffer) => output.push(data)); + child.stderr.on('data', (data: Buffer) => errors.push(data)); + const completion = new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', resolve); + }); + child.stdin.end(html); + if ((await completion) !== 0) throw new Error(Buffer.concat(errors).toString()); + return new Uint8Array(Buffer.from(Buffer.concat(output).toString(), 'base64')); + }); + try { + const response = await POST( + new Request('http://localhost:3000/api/resume', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + job_url: 'https://example.com/jobs/platform', + format: 'pdf', + save_to_history: false, + }), + }) + ); + expect(response.status).toBe(200); + const output = await response.json(); + expect(output).toMatchObject({ + ok: true, + persisted: false, + requires_review: true, + pdf: { mime_type: 'application/pdf' }, + }); + const bytes = Buffer.from(output.pdf.data_base64, 'base64'); + expect(bytes.subarray(0, 5).toString()).toBe('%PDF-'); + const parser = new PDFParse({ data: bytes }); + try { + const result = await parser.getText(); + for (const text of [ + 'Ada Example', + 'ada@example.org', + 'Node.js request validation', + 'accessible reading queue', + 'BSc Computer Science', + 'Example University, 2022', + ]) + expect(result.text).toContain(text); + const screenshots = await parser.getScreenshot({ + imageBuffer: true, + imageDataUrl: false, + desiredWidth: 1100, + }); + for (const page of screenshots.pages) + await writeFile( + join( + process.env.ROLEPATCH_PDF_EVIDENCE_DIR!, + `tailored-resume-page-${page.pageNumber}.png` + ), + page.data + ); + await writeFile(join(process.env.ROLEPATCH_PDF_EVIDENCE_DIR!, 'tailored-resume.pdf'), bytes); + await writeFile( + join(process.env.ROLEPATCH_PDF_EVIDENCE_DIR!, 'tailored-resume.txt'), + result.text + ); + await writeFile( + join(process.env.ROLEPATCH_PDF_EVIDENCE_DIR!, 'tailored-resume.json'), + JSON.stringify( + { + ...output, + pdf: { + filename: output.pdf.filename, + mime_type: output.pdf.mime_type, + byte_length: bytes.length, + }, + pages: result.total, + }, + null, + 2 + ) + ); + } finally { + await parser.destroy(); + } + } finally { + renderSpy.mockRestore(); + } +}); diff --git a/src/__tests__/resume-pdf.test.ts b/src/__tests__/resume-pdf.test.ts new file mode 100644 index 0000000..bc82a91 --- /dev/null +++ b/src/__tests__/resume-pdf.test.ts @@ -0,0 +1,94 @@ +// @vitest-environment node +import { beforeEach, expect, it, vi } from 'vitest'; +import { renderPdf } from '@/lib/pdf'; +import { MAX_PDF_BYTES, renderResumePdf } from '@/lib/resume-pdf'; +import { renderResumePdfDraft } from '@/lib/resume-pdf-draft'; +import { RESUME_MCP_TOOLS, callResumeMcpTool } from '@/lib/resume-mcp'; + +vi.mock('@/lib/pdf', () => ({ renderPdf: vi.fn() })); +vi.mock('@/lib/actions/resume-assistant-actions', () => ({ + listResumeAssistantProfiles: vi.fn(), + tailorResumeFromReference: vi.fn(), +})); +const source = '# Ada Example\n\n## Experience\n- Built scheduling APIs.\n'; +const bytes = new TextEncoder().encode('%PDF-1.7\nsynthetic bounded fixture\n%%EOF\n'); +const oversizedPdf = new Uint8Array(MAX_PDF_BYTES + 1); +oversizedPdf.set(new TextEncoder().encode('%PDF-1.7')); +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(renderPdf).mockResolvedValue(bytes); +}); + +it('returns exact named PDF bytes, sanitizing active HTML and remote images', async () => { + const pdf = await renderResumePdf( + `${source}\n\n![avatar](https://example.com/avatar.png)\n[unsafe](javascript:alert(1))` + ); + expect(pdf).toEqual({ + filename: 'tailored-resume.pdf', + mime_type: 'application/pdf', + data_base64: Buffer.from(bytes).toString('base64'), + }); + const html = vi.mocked(renderPdf).mock.calls[0][0]; + expect(html).toContain("default-src 'none'"); + expect(html).not.toMatch(/renderer error'), oversizedPdf])( + 'rejects empty, invalid-signature and oversized renderer output', + async (output) => { + vi.mocked(renderPdf).mockResolvedValue(output); + expect(await renderResumePdfDraft({ resume_markdown: source })).toMatchObject({ + ok: false, + code: 'pdf_render_failed', + markdown: source, + persisted: false, + retryable: true, + }); + } +); + +it('bounds render-only inputs and rejects generation/history fields without invoking renderer', async () => { + for (const raw of [ + { resume_markdown: 'x'.repeat(40001) }, + { resume_markdown: ' '.repeat(20) }, + { resume_markdown: source, save_to_history: true }, + { resume_markdown: source, job_url: 'https://example.com/job' }, + { resume_id: 'owned' }, + { operation: 'render_pdf', resume_markdown: source }, + ]) { + expect(await renderResumePdfDraft(raw)).toMatchObject({ ok: false, code: 'invalid_input' }); + } + expect(renderPdf).not.toHaveBeenCalled(); +}); + +it('returns recoverable text without leaking renderer errors and retries only rendering', async () => { + vi.mocked(renderPdf).mockRejectedValueOnce(new Error('private internal browser path')); + const failed = await renderResumePdfDraft({ resume_markdown: source }); + expect(failed).toMatchObject({ ok: false, code: 'pdf_render_failed', markdown: source }); + expect(JSON.stringify(failed)).not.toContain('private internal'); + expect( + await callResumeMcpTool('rolepatch_render_resume_pdf', { resume_markdown: source }) + ).toMatchObject({ + ok: true, + requires_review: true, + persisted: false, + pdf: { mime_type: 'application/pdf' }, + }); +}); + +it('discovers PDF format and strict render-only retry tool while preserving Markdown default', () => { + expect( + RESUME_MCP_TOOLS.find((tool) => tool.name === 'rolepatch_tailor_resume')?.inputSchema.properties + ?.format + ).toMatchObject({ + enum: ['markdown', 'html', 'pdf'], + default: 'markdown', + }); + expect( + RESUME_MCP_TOOLS.find((tool) => tool.name === 'rolepatch_render_resume_pdf') + ).toMatchObject({ + inputSchema: { required: ['resume_markdown'], additionalProperties: false }, + annotations: { readOnlyHint: true }, + }); +}); diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index 865ffd5..68417c9 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -118,6 +118,25 @@ export async function POST(req: Request) { req, async () => { const output = await callResumeMcpTool(name!, message.params?.arguments ?? {}); + if (output.ok && 'pdf' in output && output.pdf) { + const { data_base64, ...artifact } = output.pdf; + // This URI identifies the inline attachment, not a fetchable stored draft. + const uri = `rolepatch://drafts/${output.draft_id}/resume.pdf`; + const metadata = { ...output, pdf: { ...artifact, uri } }; + return rpc(id, { + result: { + isError: false, + content: [ + { type: 'text', text: JSON.stringify(metadata) }, + { + type: 'resource', + resource: { uri, mimeType: artifact.mime_type, blob: data_base64 }, + }, + ], + structuredContent: metadata, + }, + }); + } return rpc(id, { result: { isError: !output.ok, diff --git a/src/app/api/resume/route.ts b/src/app/api/resume/route.ts index 788a727..fb44790 100644 --- a/src/app/api/resume/route.ts +++ b/src/app/api/resume/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { tailorResumeFromReference } from '@/lib/actions/resume-assistant-actions'; import { withResumeAccess } from '@/lib/resume-access'; +import { renderResumePdfDraft } from '@/lib/resume-pdf-draft'; export const dynamic = 'force-dynamic'; const limits = new Map(); @@ -55,14 +56,24 @@ export async function POST(req: Request) { limits.set(key, state); try { return await withResumeAccess(req, async () => { - const result = await tailorResumeFromReference(input, true); + const result = + input && + typeof input === 'object' && + 'operation' in input && + input.operation === 'render_pdf' + ? await renderResumePdfDraft( + Object.fromEntries(Object.entries(input).filter(([key]) => key !== 'operation')) + ) + : await tailorResumeFromReference(input, true); const status = result.ok ? 200 : result.code === 'sign_in_required' ? 401 : result.code === 'not_found' ? 404 - : result.code === 'generation_failed' || result.code === 'extraction_failed' + : result.code === 'generation_failed' || + result.code === 'extraction_failed' || + result.code === 'pdf_render_failed' ? 502 : 400; return NextResponse.json(result, { status, headers }); diff --git a/src/lib/actions/resume-assistant-actions.ts b/src/lib/actions/resume-assistant-actions.ts index 208c385..81f7b44 100644 --- a/src/lib/actions/resume-assistant-actions.ts +++ b/src/lib/actions/resume-assistant-actions.ts @@ -1,5 +1,3 @@ -'use server'; - import { getJobApplication } from '@/lib/actions/job-actions'; import { getResume, listResumes } from '@/lib/actions/resume-actions'; import { tailorResumeForClient } from '@/lib/actions/tailor-action'; @@ -12,6 +10,7 @@ import { resumeAssistantSchema, } from '@/lib/resume-assistant-input'; import { markdownToHtml } from '@/lib/resume-html'; +import { renderResumePdfDraft } from '@/lib/resume-pdf-draft'; export async function listResumeAssistantProfiles() { if (!(await getCurrentUserId())) @@ -180,6 +179,25 @@ export async function tailorResumeFromReference(raw: unknown, historyByDefault = retryable: result.retryable, }; const draftId = crypto.randomUUID(); + let pdf: Awaited> | undefined; + if (input.format === 'pdf') { + pdf = await renderResumePdfDraft({ resume_markdown: result.data.tailored }); + if (!pdf.ok) + return { + ...pdf, + draft_id: draftId, + requires_review: true, + persisted: false, + history: { saved: false }, + markdown: result.data.tailored, + changes: result.data.changes, + generation_method: result.data.generation_method ?? 'ai', + ...(result.data.generation_method === 'source_fallback' + ? { fallback_reason: result.data.fallback_reason } + : {}), + job_url: ref.url ?? job?.url ?? null, + }; + } const history = saveHistory ? await saveDraftHistory({ resume_id: historyResumeId, @@ -206,6 +224,7 @@ export async function tailorResumeFromReference(raw: unknown, historyByDefault = ...(result.data.generation_method === 'source_fallback' ? { fallback_reason: result.data.fallback_reason } : {}), + ...(pdf?.ok ? { pdf: pdf.pdf } : {}), ...(input.format === 'html' ? { html: markdownToHtml(result.data.tailored, 'Tailored resume') } : {}), diff --git a/src/lib/resume-assistant-input.ts b/src/lib/resume-assistant-input.ts index db8bc21..c4a0fd1 100644 --- a/src/lib/resume-assistant-input.ts +++ b/src/lib/resume-assistant-input.ts @@ -16,7 +16,7 @@ export const resumeAssistantSchema = z company_name: z.string().trim().max(200).optional(), role_title: z.string().trim().max(200).optional(), save_to_history: z.boolean().optional(), - format: z.enum(['markdown', 'html']).default('markdown'), + format: z.enum(['markdown', 'html', 'pdf']).default('markdown'), }) .strict(); export type ResumeAssistantInput = z.infer; diff --git a/src/lib/resume-mcp.ts b/src/lib/resume-mcp.ts index a15243e..75df3f1 100644 --- a/src/lib/resume-mcp.ts +++ b/src/lib/resume-mcp.ts @@ -3,6 +3,7 @@ import { tailorResumeFromReference, } from '@/lib/actions/resume-assistant-actions'; import { resolveJobReference, jobReferenceSchema } from '@/lib/resume-assistant-input'; +import { renderResumePdfDraft } from '@/lib/resume-pdf-draft'; export const RESUME_MCP_TOOLS = [ { @@ -32,7 +33,7 @@ export const RESUME_MCP_TOOLS = [ { name: 'rolepatch_tailor_resume', description: - 'Return a complete tailored Markdown resume, grounded change explanations and optional HTML. Provide the base resume and a public job URL/board-qualified ID or pasted job description. Guests are stateless; signed-in callers may use owned saved IDs. Signed-in generation uses one token. The original is preserved, output needs review (unsaved by default; explicitly request save_to_history for owned cloud history), and no application is submitted.', + 'Return a complete tailored Markdown resume, grounded change explanations and optional HTML or PDF attachment. Provide a public job URL/board-qualified ID or pasted description. Connected accounts with one owned master may omit resume input; otherwise choose resume_id or supply resume_markdown. Guests are stateless. Signed-in generation uses one token. The original is preserved, output needs review (unsaved by default; explicitly request save_to_history for owned cloud history), and no application is submitted. On PDF failure, keep the returned draft and retry with rolepatch_render_resume_pdf without generating again.', inputSchema: { type: 'object', properties: { @@ -43,7 +44,7 @@ export const RESUME_MCP_TOOLS = [ jd_text: { type: 'string' }, resume_id: { type: 'string' }, resume_markdown: { type: 'string' }, - format: { type: 'string', enum: ['markdown', 'html'], default: 'markdown' }, + format: { type: 'string', enum: ['markdown', 'html', 'pdf'], default: 'markdown' }, save_to_history: { type: 'boolean', default: false }, company_name: { type: 'string' }, role_title: { type: 'string' }, @@ -57,9 +58,22 @@ export const RESUME_MCP_TOOLS = [ openWorldHint: true, }, }, + { + name: 'rolepatch_render_resume_pdf', + description: + 'Render supplied Markdown draft text as a PDF attachment without AI generation, generation credits, saved-profile reads or persistence. Use this to retry a failed PDF export. The caller must provide the returned markdown; draft IDs are not stored downloads. Output requires review.', + inputSchema: { + type: 'object', + properties: { resume_markdown: { type: 'string', minLength: 20, maxLength: 40000 } }, + required: ['resume_markdown'], + additionalProperties: false, + }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + }, ]; export async function callResumeMcpTool(name: string, args: unknown) { + if (name === 'rolepatch_render_resume_pdf') return renderResumePdfDraft(args); if (name === 'rolepatch_resume_profiles') { if (!args || typeof args !== 'object' || Array.isArray(args) || Object.keys(args).length) return { ok: false, error: 'This tool accepts an empty object.' }; diff --git a/src/lib/resume-pdf-draft.ts b/src/lib/resume-pdf-draft.ts new file mode 100644 index 0000000..0af6cfa --- /dev/null +++ b/src/lib/resume-pdf-draft.ts @@ -0,0 +1,41 @@ +import { z } from 'zod'; +import { MAX_PDF_RESUME_CHARS, renderResumePdf } from '@/lib/resume-pdf'; + +const inputSchema = z + .object({ + resume_markdown: z + .string() + .min(20) + .max(MAX_PDF_RESUME_CHARS) + .refine((text) => text.trim().length >= 20), + }) + .strict(); + +/** Renders supplied draft text only: no generation, profile reads, credits or writes. */ +export async function renderResumePdfDraft(raw: unknown) { + const parsed = inputSchema.safeParse(raw); + if (!parsed.success) + return { + ok: false as const, + code: 'invalid_input', + error: 'Supply only resume_markdown (20–40,000 characters) to render a PDF.', + }; + const draft = { + draft_id: crypto.randomUUID(), + requires_review: true as const, + persisted: false as const, + markdown: parsed.data.resume_markdown, + }; + try { + return { ok: true as const, ...draft, pdf: await renderResumePdf(draft.markdown) }; + } catch { + return { + ok: false as const, + code: 'pdf_render_failed', + error: + 'PDF export failed. Keep the returned markdown and retry rendering with operation: "render_pdf" on /api/resume or rolepatch_render_resume_pdf. Rendering uses no generation credits; do not repeat tailoring.', + retryable: true, + ...draft, + }; + } +} diff --git a/src/lib/resume-pdf.ts b/src/lib/resume-pdf.ts new file mode 100644 index 0000000..3e441bc --- /dev/null +++ b/src/lib/resume-pdf.ts @@ -0,0 +1,24 @@ +import { renderPdf } from '@/lib/pdf'; +import { markdownToHtml } from '@/lib/resume-html'; + +// A generated draft may include the 20k master, 10k stash and generated summary. +export const MAX_PDF_RESUME_CHARS = 40_000; +export const MAX_PDF_BYTES = 2 * 1024 * 1024; + +export async function renderResumePdf(source: string) { + if (!source.trim() || source.length > MAX_PDF_RESUME_CHARS) + throw new Error('Invalid resume size'); + // The shared formatter escapes raw HTML/images and supplies a no-network CSP. + const bytes = await renderPdf(markdownToHtml(source, 'Tailored resume')); + if ( + bytes.byteLength < 20 || + bytes.byteLength > MAX_PDF_BYTES || + new TextDecoder().decode(bytes.subarray(0, 5)) !== '%PDF-' + ) + throw new Error('Invalid PDF output'); + return { + filename: 'tailored-resume.pdf', + mime_type: 'application/pdf' as const, + data_base64: Buffer.from(bytes).toString('base64'), + }; +}