From 304d189ec4e0346d92e6f0b53a3ca0d813e3e699 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 9 Sep 2026 11:17:52 +0000 Subject: [PATCH] 0.7.0: a resume is public, the way to reach the person is not Anyone could read a published resume and get the candidate's email address and phone number out of it, on the page, in the JSON, in the RSS-adjacent Markdown and in all four downloads. Walking /candidates and following each link produced a mailing list with phone numbers attached, which is not what somebody agreed to by publishing a resume in order to be hired. Contact channels now need a session or a token. Everything else about the resume stays public, because a candidate directory nobody can read is a candidate directory nobody uses. What counts as a channel falls out of the parse rather than out of a list of key names, so it does not go stale as people invent new fields: a contact bullet that produced an href is a way to reach someone (mailto, tel, a profile), and one that did not is a plain fact. "Location: Lisbon" and "Work Authorization: EU Citizen" stay. The directory already prints the location on every card and filtering on it is the point, so withholding it there would be theatre. The withheld bullets are replaced by one that says so, rather than removed. A caller that cannot tell a redacted document from a resume with no contact details will report the second as the first, and the JSON carries contactRedacted so an agent can tell without guessing. Redaction is Markdown in, Markdown out, and the routes re-parse the result instead of being handed a doctored parse. The document and the parse of it can never disagree about what was withheld, which is the same rule the rest of OpenResume runs on. Every representation goes through one helper, because they all render the same Markdown and gating the page while forgetting the PDF is how the address stays public on a board that looks careful. The one that cannot be redacted is an original upload: those bytes are whatever the candidate typeset, so an anonymous caller asking for .pdf or .docx no longer gets the shortcut and gets a copy generated from the redacted Markdown instead. Signed in is the whole test, deliberately not "signed in and approved". A person with a session and an agent with a device token are the same caller here, since an agent reading resumes for its owner is the traffic this board exists to serve. What changes is that there is an account behind the read, which is the thing a scraper does not want to have. Tested end to end against a real Postgres: the page, the JSON, resume.md and resume.html all withhold the address anonymously and all return it with a token. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015xXMzJf85q87oeG3VEKHdJ --- package.json | 2 +- src/config.ts | 2 +- src/core/candidates.ts | 38 ++++++++++ src/markup/resume.ts | 73 +++++++++++++++++++ src/server/routes/api.ts | 22 +++++- src/server/routes/discovery.ts | 5 ++ src/server/routes/openapi.ts | 6 +- src/server/routes/pages.tsx | 31 +++++--- src/views/candidates.tsx | 18 ++++- test/api.test.ts | 62 ++++++++++++++++ test/resume.test.ts | 125 ++++++++++++++++++++++++++++++++- 11 files changed, 368 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index 57a1872..2d28e70 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/agenticjobs", - "version": "0.6.1", + "version": "0.7.0", "description": "An agent-friendly job board you self-host. It posts its own jobs, never scrapes anyone else's, and answers on every surface: web, API, MCP, CLI, TUI, desktop and PWA. Instances find each other through an open directory.", "license": "MIT", "type": "module", diff --git a/src/config.ts b/src/config.ts index c7d5145..da84710 100644 --- a/src/config.ts +++ b/src/config.ts @@ -106,7 +106,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { } /** Kept in step with package.json by the release script. */ -export const VERSION = '0.6.1'; +export const VERSION = '0.7.0'; export const SOFTWARE_NAME = 'agenticjobs'; /** diff --git a/src/core/candidates.ts b/src/core/candidates.ts index 6948fc1..66cd0f1 100644 --- a/src/core/candidates.ts +++ b/src/core/candidates.ts @@ -9,6 +9,7 @@ import type { Resume } from './resumes.ts'; import type { CandidateSummary } from '../views/candidates.tsx'; +import { type OpenResume, parseResume, redactContactChannels } from '../markup/resume.ts'; /** Contact keys that read as a place rather than an address. */ const LOCATION_KEYS = /^(location|based|city|where|region)$/i; @@ -83,6 +84,43 @@ export function nameOf(resume: Resume): string { return title === '' || title.length > NAME_MAX ? 'Candidate' : title; } +/** One resume as a given viewer is allowed to see it. */ +export interface ResumeView { + markdown: string; + parsed: OpenResume | null; + /** True when contact channels were withheld from this copy. */ + redacted: boolean; +} + +/** + * The copy of a resume to serve, given whether anybody is signed in. + * + * Every representation goes through here: the page, the JSON, the RSS, and + * each of the four download formats. They all render the same Markdown, so + * gating in one of them and forgetting another is how the address ends up + * public in the PDF while the page looks careful. + * + * Signed in is the whole test, and it is deliberately not "signed in and + * approved". A person browsing with a session and an agent holding a device + * token are the same caller here, because an agent reading resumes on its + * owner's behalf is the traffic this board exists to serve. What changes is + * that there is now an account behind the read, which is the thing a scraper + * does not want to have. + */ +export function resumeForViewer( + resume: { markdown: string; parsed: OpenResume | null }, + signedIn: boolean, +): ResumeView { + if (signedIn) return { markdown: resume.markdown, parsed: resume.parsed, redacted: false }; + + const { markdown, redacted } = redactContactChannels(resume.markdown); + // Nothing to withhold: hand back the original parse rather than paying for + // a second one, and report honestly that this copy is whole. + if (!redacted) return { markdown: resume.markdown, parsed: resume.parsed, redacted: false }; + + return { markdown, parsed: parseResume(markdown), redacted: true }; +} + export function toCandidateSummary(resume: Resume): CandidateSummary { return { slug: resume.publicSlug ?? '', diff --git a/src/markup/resume.ts b/src/markup/resume.ts index bd90e95..9a699e0 100644 --- a/src/markup/resume.ts +++ b/src/markup/resume.ts @@ -320,3 +320,76 @@ export function resumeTemplate(name = 'Your Name'): string { '', ].join('\n'); } + +/** What replaces the withheld bullets, so a redacted block says it is one. */ +export const CONTACT_WITHHELD = 'shared with signed-in members'; + +/** + * The contact block, minus every way to actually reach the person. + * + * A published resume is a document its owner chose to make public, but the + * contact block inside it is the part worth harvesting on its own. An + * anonymous crawler that walks /candidates and follows each link gets a + * mailing list with phone numbers attached, and somebody who published a + * resume in order to be hired did not agree to that. + * + * The rule falls out of the parse rather than out of a list of key names, + * which is what keeps it from going stale as people invent new fields. A + * contact field that produced an `href` is a channel: mailto, tel, or a + * profile somewhere. One that did not is a plain fact, like "Location: + * Berlin" or "Work Authorization: EU Citizen", and those stay. The directory + * already prints the location on every card and filtering on it is the point, + * so withholding it here would be theatre. + * + * The withheld bullets are replaced by one saying so rather than removed + * silently. A caller that cannot tell a redacted document from a resume with + * no contact details will read the second as the first, and this is the one + * place where being quietly wrong is worse than being unhelpful. + * + * Markdown in, Markdown out. The caller re-parses the result instead of being + * handed a doctored parse, so the document and the parse of it can never + * disagree about what was withheld. That is the rule the rest of OpenResume + * runs on: the Markdown is the canonical copy. + */ +export function redactContactChannels(source: string): { markdown: string; redacted: boolean } { + const markdown = source.replace(/\r\n?/g, '\n'); + const out: string[] = []; + let seenH1 = false; + let inPreamble = false; + let redacted = false; + + for (const line of markdown.split('\n')) { + if (/^#\s+(.+?)\s*#*\s*$/.test(line)) { + if (!seenH1) { + seenH1 = true; + inPreamble = true; + } + out.push(line); + continue; + } + + if (/^##\s+(.+?)\s*#*\s*$/.test(line)) { + inPreamble = false; + out.push(line); + continue; + } + + if (inPreamble) { + const bullet = /^\s*[-*+]\s+(.*)$/.exec(line); + if (bullet !== null) { + const field = parseContact(bullet[1] ?? ''); + if (field !== null && field.href !== null) { + // The first one withheld becomes the notice, so the block keeps its + // shape and its place instead of collapsing to nothing. + if (!redacted) out.push(`- **Contact**: ${CONTACT_WITHHELD}`); + redacted = true; + continue; + } + } + } + + out.push(line); + } + + return redacted ? { markdown: out.join('\n'), redacted } : { markdown, redacted }; +} diff --git a/src/server/routes/api.ts b/src/server/routes/api.ts index 7416a78..39c50e5 100644 --- a/src/server/routes/api.ts +++ b/src/server/routes/api.ts @@ -63,7 +63,12 @@ import { } from '../../core/resumes.ts'; import { importDocument, ImportProblem, MAX_UPLOAD_BYTES } from '../../core/import.ts'; import { deliverMagicLink } from '../../core/mail.ts'; -import { tagsFrom, toCandidateSummary, withTags } from '../../core/candidates.ts'; +import { + resumeForViewer, + tagsFrom, + toCandidateSummary, + withTags, +} from '../../core/candidates.ts'; import { candidateSlugFor, deleteUpdate, @@ -701,12 +706,23 @@ export function apiRoutes(): Hono { if (resume === null) { return fail(c, 404, 'not_found', 'No such candidate, or that resume is not shared.'); } + // Reading a resume is public; the contact block inside it is not. An + // anonymous caller gets the document with every channel withheld and is + // told so in the payload, because an agent that cannot see the difference + // will report "no contact details on file" and be wrong. + const shown = resumeForViewer(resume, c.get('viewer') !== null); return c.json({ candidate: toCandidateSummary(resume), // The Markdown is the canonical document, so it travels whole rather // than only as the parse of it. - markdown: resume.markdown, - parsed: resume.parsed, + markdown: shown.markdown, + parsed: shown.parsed, + ...(shown.redacted + ? { + contactRedacted: true, + contactHint: 'Sign in, or send a token, to read the contact block.', + } + : {}), listed: resume.visibility === 'public', url: `${config.publicUrl}/candidates/${resume.publicSlug}`, spec: `${config.publicUrl}/docs/openresume`, diff --git a/src/server/routes/discovery.ts b/src/server/routes/discovery.ts index c2659da..dff1a3d 100644 --- a/src/server/routes/discovery.ts +++ b/src/server/routes/discovery.ts @@ -713,6 +713,11 @@ export function discoveryRoutes(): Hono { `People are the same, filtered by tag: ${config.publicUrl}/candidates.md?tags=javascript,`, `with ${config.publicUrl}/api/v1/candidates and ${config.publicUrl}/candidates/feed alongside.`, '', + 'Resumes are readable without a credential, but the contact block is not. Read one', + 'anonymously and the addresses and phone numbers are withheld, with contactRedacted', + 'set so you can tell that copy from a resume that never listed any. Send the token', + 'from `agenticjobs login` to read them.', + '', '## Updates', '', 'Employers and candidates post short updates: hiring news, what shipped, who is free', diff --git a/src/server/routes/openapi.ts b/src/server/routes/openapi.ts index ffacc8c..c6fc8ec 100644 --- a/src/server/routes/openapi.ts +++ b/src/server/routes/openapi.ts @@ -186,7 +186,11 @@ export function openApiDocument(config: Config): Record { tags: ['candidates'], summary: 'One candidate, with their resume as Markdown. Public.', description: - 'Serves a link-shared resume as well as a listed one. The Markdown is canonical.', + 'Serves a link-shared resume as well as a listed one. The Markdown is canonical. ' + + 'Called without a session or token, the contact block comes back with every ' + + 'channel withheld and contactRedacted set to true; the rest of the document is ' + + 'whole, and facts that are not channels, such as location, stay. Send a token to ' + + 'read the addresses.', parameters: [pathParam('slug')], responses: { 200: ok('The candidate.'), 404: err() }, }, diff --git a/src/server/routes/pages.tsx b/src/server/routes/pages.tsx index c283609..c873827 100644 --- a/src/server/routes/pages.tsx +++ b/src/server/routes/pages.tsx @@ -66,7 +66,13 @@ import { ManageJobPage, NewEmployerPage, PostJobPage } from '../../views/post.ts import { NetworkPage, NetworkSearchPage } from '../../views/network.tsx'; import { DocsPage, SpecPage } from '../../views/docs.tsx'; import { CandidateDetail, CandidateList } from '../../views/candidates.tsx'; -import { allTags, tagsFrom, toCandidateSummary, withTags } from '../../core/candidates.ts'; +import { + allTags, + resumeForViewer, + tagsFrom, + toCandidateSummary, + withTags, +} from '../../core/candidates.ts'; import { BODY_MAX, candidateSlugFor, @@ -307,6 +313,7 @@ export function pageRoutes(): Hono { const summary = toCandidateSummary(resume); const viewer = c.get('viewer'); + const shown = resumeForViewer(resume, viewer !== null); const target: Target = { kind: 'candidate', userId: resume.userId }; const [updates, followers, following] = await Promise.all([ listUpdatesFor(pool, target), @@ -344,10 +351,11 @@ export function pageRoutes(): Hono { > , @@ -398,16 +406,23 @@ export function pageRoutes(): Hono { const resume = await getPublicResume(pool, slug); if (resume === null) return c.notFound(); const name = toCandidateSummary(resume).name; + const shown = resumeForViewer(resume, c.get('viewer') !== null); if (format === 'md') { - return c.body(resume.markdown, 200, { + return c.body(shown.markdown, 200, { 'content-type': 'text/markdown; charset=utf-8', 'content-disposition': `attachment; filename="${filename(name, 'html').replace(/\.html$/, '.md')}"`, }); } // The original upload, when it is the thing being asked for. - if (format === 'pdf' || format === 'docx') { + // + // Not to an anonymous caller once anything has been withheld: those bytes + // are whatever the candidate uploaded, the contact block included, and + // there is no redacting a PDF somebody else typeset. Skipping the + // shortcut falls through to a copy generated from the redacted Markdown, + // which is the same thing the page is showing them. + if ((format === 'pdf' || format === 'docx') && !shown.redacted) { const source = await publicResumeSource(pool, slug); const wanted = format === 'pdf' ? 'pdf' : 'wordprocessingml'; if (source !== null && source.mime.includes(wanted)) { @@ -419,8 +434,8 @@ export function pageRoutes(): Hono { } const html = resumeHtml({ - markdown: resume.markdown, - parsed: resume.parsed, + markdown: shown.markdown, + parsed: shown.parsed, title: resume.title, }); if (format === 'html') { @@ -428,7 +443,7 @@ export function pageRoutes(): Hono { } try { - const bytes = format === 'pdf' ? await resumePdf(html) : await resumeDocx(resume.markdown); + const bytes = format === 'pdf' ? await resumePdf(html) : await resumeDocx(shown.markdown); return c.body(new Uint8Array(bytes), 200, { 'content-type': CONTENT_TYPE[format], 'content-disposition': `attachment; filename="${filename(name, format)}"`, diff --git a/src/views/candidates.tsx b/src/views/candidates.tsx index 4af38cb..814c6bf 100644 --- a/src/views/candidates.tsx +++ b/src/views/candidates.tsx @@ -167,9 +167,11 @@ export const CandidateDetail: FC<{ html: string; markdownUrl: string; listed: boolean; + /** True when this viewer is being shown the resume without its contact block. */ + contactRedacted?: boolean; /** Follow, updates and, for the person themselves, the composer. */ social?: SocialProps; -}> = ({ candidate, parsed, html, markdownUrl, listed, social }) => ( +}> = ({ candidate, parsed, html, markdownUrl, listed, contactRedacted = false, social }) => (
@@ -215,6 +217,14 @@ export const CandidateDetail: FC<{ ))} + {contactRedacted && ( +

+ + Sign in + {' '} + to see how to reach {candidate.name}. An agent holding a token sees them too. +

+ )} )} @@ -249,6 +259,12 @@ export const CandidateDetail: FC<{

The Markdown is the canonical document. The spec.

+ {contactRedacted && ( +

+ Called without a token it comes back with the contact block withheld and{' '} + contactRedacted set, the same as this page. +

+ )} {!listed && ( diff --git a/test/api.test.ts b/test/api.test.ts index f8aaacc..96ef582 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -731,6 +731,68 @@ describe('the API', { skip: reason === '' ? false : `no database: ${reason}` }, assert.match(html, /noindex/); }); + test('contact channels are for signed-in callers, in every representation', async () => { + if (pool === null) return; + const { createSession, ensureUser } = await import('../dist/core/auth.js'); + const { createResume, updateResume, ensurePublicSlug } = await import( + '../dist/core/resumes.js' + ); + const user = await ensureUser(pool as never, `cand+${Date.now()}+gate@example.com`); + const token = await createSession(pool as never, user.id, { label: 't' }); + const created = await createResume(pool as never, user.id, { + markdown: [ + '# Reachable Person', + '', + '- **Email**: reachable@example.com', + '- **Phone**: +1 (408) 555-0142', + '- **Location**: Lisbon', + '', + '## Skills', + '', + '- Go', + '', + ].join('\n'), + title: 'Reachable', + }); + const saved = await updateResume(pool as never, user.id, created.slug, { + markdown: created.markdown, + visibility: 'public', + }); + const slug = await ensurePublicSlug(pool as never, saved); + const auth = { authorization: `Bearer ${token}` }; + + // Every representation renders the same Markdown, so each one is checked + // rather than assumed: gating the page and forgetting the PDF is how the + // address stays public while the board looks careful. + const json = (await (await get(`/api/v1/candidates/${slug}`)).json()) as { + markdown: string; + contactRedacted?: boolean; + }; + assert.equal(json.contactRedacted, true, 'an agent is told the copy is partial'); + assert.ok(!json.markdown.includes('reachable@example.com'), json.markdown); + assert.ok(!json.markdown.includes('555-0142')); + assert.ok(json.markdown.includes('Lisbon'), 'a location is a fact, not a channel'); + + const page = await (await get(`/candidates/${slug}`, { accept: 'text/html' })).text(); + assert.ok(!page.includes('reachable@example.com'), 'not in the body and not in the sidebar'); + assert.ok(!page.includes('555-0142')); + + const md = await (await get(`/candidates/${slug}/resume.md`)).text(); + assert.ok(!md.includes('reachable@example.com'), md); + const html = await (await get(`/candidates/${slug}/resume.html`)).text(); + assert.ok(!html.includes('reachable@example.com'), 'the print rendering too'); + + // A session or a device token, either one, reads the whole document. + const member = (await (await get(`/api/v1/candidates/${slug}`, auth)).json()) as { + markdown: string; + contactRedacted?: boolean; + }; + assert.equal(member.contactRedacted, undefined); + assert.ok(member.markdown.includes('reachable@example.com')); + const memberMd = await (await get(`/candidates/${slug}/resume.md`, auth)).text(); + assert.ok(memberMd.includes('555-0142'), memberMd); + }); + test('a resume that lost its line breaks cannot become a paragraph-long URL', async () => { // A flattened resume parses as one h1 holding the whole document, so the // "name" is the entire CV. Unbounded, that produced a three thousand diff --git a/test/resume.test.ts b/test/resume.test.ts index 8e69b75..a875ec1 100644 --- a/test/resume.test.ts +++ b/test/resume.test.ts @@ -7,7 +7,12 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { parseResume, resumeTemplate, resumeSearchText } from '../dist/markup/resume.js'; +import { + parseResume, + redactContactChannels, + resumeTemplate, + resumeSearchText, +} from '../dist/markup/resume.js'; const FULL = `# Ada Lovelace @@ -162,3 +167,121 @@ test('a skills badge is a skill, not the category it sits under', async () => { 'JavaScript', 'TypeScript', 'Go', 'PostgreSQL', 'Docker', ]); }); + +/** + * Contact channels are for signed-in callers. + * + * A public resume is still public. What is gated is the block that is worth + * harvesting on its own, and the test of what counts is whether the parse + * produced a link: a channel is a way to reach someone, a location is a fact + * about them and the directory already shows it. + */ + +const WITH_FACTS = [ + '# Ada Lovelace', + '', + '- **Email**: ada@example.com', + '- **Phone**: +1 (408) 555-0100', + '- **Location**: London, England', + '- **Work Authorization**: UK Citizen', + '- [GitHub](https://github.com/ada)', + '', + 'Mathematician.', + '', + '## Links', + '', + '- [Notes](https://example.com/notes)', + '', + '## Work Experience', + '', + '### Analytical Engine | London', + 'Chief Programmer (1842 - 1843)', + '', + '- Wrote the first published algorithm.', +].join('\n'); + +test('a contact channel is withheld and a plain fact is not', () => { + const { markdown, redacted } = redactContactChannels(WITH_FACTS); + assert.equal(redacted, true); + + assert.ok(!markdown.includes('ada@example.com'), 'the address is gone'); + assert.ok(!markdown.includes('555-0100'), 'the phone number is gone'); + assert.ok(!markdown.includes('github.com/ada'), 'the profile link is gone'); + + // These read as facts about the person, not as ways to reach them, and the + // candidate card prints the location whether or not anybody is signed in. + assert.ok(markdown.includes('London, England'), 'the location stays'); + assert.ok(markdown.includes('UK Citizen'), 'work authorization stays'); +}); + +test('what was withheld says so, in the place it was withheld from', () => { + const { markdown } = redactContactChannels(WITH_FACTS); + const parsed = parseResume(markdown); + + // Re-parsing the redacted Markdown is how the routes build the object they + // serve, so the document and its parse can never disagree. + const keys = parsed.contact.map((item) => item.key.toLowerCase()); + assert.deepEqual(keys, ['contact', 'location', 'work authorization']); + assert.equal(parsed.contact[0]?.value, 'shared with signed-in members'); + assert.equal(parsed.contact[0]?.href, null, 'the notice is not itself a link'); + + // A caller that cannot tell this from a resume with no contact details will + // report the second as the first. + assert.ok(parsed.contact.length > 0, 'the block is not simply emptied'); + assert.equal(parsed.name, 'Ada Lovelace', 'the rest of the document is untouched'); +}); + +test('only the contact block is redacted, not every link in the resume', () => { + const { markdown } = redactContactChannels(WITH_FACTS); + assert.ok( + markdown.includes('https://example.com/notes'), + 'a link under a heading is content, not a contact channel', + ); + assert.ok(markdown.includes('Wrote the first published algorithm.')); + assert.ok(markdown.includes('## Work Experience')); +}); + +test('a resume with nothing to withhold is not reported as redacted', () => { + const plain = ['# A Person', '', '- **Location**: Berlin', '', '## Skills', '', '- Go'].join('\n'); + const { markdown, redacted } = redactContactChannels(plain); + assert.equal(redacted, false); + assert.equal(markdown, plain, 'and it comes back untouched'); +}); + +test('resumeForViewer gates on being signed in, and nothing else', async () => { + const { resumeForViewer } = await import('../dist/core/candidates.js'); + const resume = { markdown: WITH_FACTS, parsed: parseResume(WITH_FACTS) }; + + const member = resumeForViewer(resume, true); + assert.equal(member.redacted, false); + assert.equal(member.markdown, WITH_FACTS, 'a signed-in caller reads the whole document'); + assert.ok(member.markdown.includes('ada@example.com')); + + // An agent with a device token arrives here as a viewer too, which is the + // point: the board is for agents reading on somebody's behalf. + const anonymous = resumeForViewer(resume, false); + assert.equal(anonymous.redacted, true); + assert.ok(!anonymous.markdown.includes('ada@example.com')); + assert.ok( + !JSON.stringify(anonymous.parsed).includes('ada@example.com'), + 'the parse is rebuilt from the redacted Markdown, not passed through', + ); +}); + +test('a redacted resume still has a location for the directory card', async () => { + const { resumeForViewer, toCandidateSummary } = await import('../dist/core/candidates.js'); + const anonymous = resumeForViewer( + { markdown: WITH_FACTS, parsed: parseResume(WITH_FACTS) }, + false, + ); + + const summary = toCandidateSummary({ + id: 'x', userId: 'u', slug: 's', title: 'T', + markdown: anonymous.markdown, parsed: anonymous.parsed, + visibility: 'public', publicSlug: 's', + sourceName: null, createdAt: '', updatedAt: '', + }); + + assert.equal(summary.name, 'Ada Lovelace'); + assert.equal(summary.location, 'London, England'); +});