Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions docs/operations/runbooks/resume-mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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):

Expand Down Expand Up @@ -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": "<returned draft 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.
1 change: 1 addition & 0 deletions knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
21 changes: 21 additions & 0 deletions src/__tests__/fixtures/render-pdf.mjs
Original file line number Diff line number Diff line change
@@ -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'));
167 changes: 166 additions & 1 deletion src/__tests__/resume-access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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: () => ({}),
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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);
});

Expand Down Expand Up @@ -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);
});
27 changes: 27 additions & 0 deletions src/__tests__/resume-api-route.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>) =>
new Request('https://rolepatch.com/api/resume', {
method: 'POST',
Expand Down Expand Up @@ -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({
Expand All @@ -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<ReturnType<typeof renderResumePdfDraft>>
);
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);
Expand Down
Loading
Loading