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'); +});