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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down
38 changes: 38 additions & 0 deletions src/core/candidates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 ?? '',
Expand Down
73 changes: 73 additions & 0 deletions src/markup/resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
22 changes: 19 additions & 3 deletions src/server/routes/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -701,12 +706,23 @@ export function apiRoutes(): Hono<AppEnv> {
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`,
Expand Down
5 changes: 5 additions & 0 deletions src/server/routes/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,11 @@ export function discoveryRoutes(): Hono<AppEnv> {
`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',
Expand Down
6 changes: 5 additions & 1 deletion src/server/routes/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,11 @@ export function openApiDocument(config: Config): Record<string, unknown> {
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() },
},
Expand Down
31 changes: 23 additions & 8 deletions src/server/routes/pages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -307,6 +313,7 @@ export function pageRoutes(): Hono<AppEnv> {

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),
Expand Down Expand Up @@ -344,10 +351,11 @@ export function pageRoutes(): Hono<AppEnv> {
>
<CandidateDetail
candidate={summary}
parsed={resume.parsed}
html={renderMarkdown(resume.markdown, { headingOffset: 1 })}
parsed={shown.parsed}
html={renderMarkdown(shown.markdown, { headingOffset: 1 })}
markdownUrl={`${config.publicUrl}/api/v1/candidates/${summary.slug}`}
listed={resume.visibility === 'public'}
contactRedacted={shown.redacted}
social={social}
/>
</Layout>,
Expand Down Expand Up @@ -398,16 +406,23 @@ export function pageRoutes(): Hono<AppEnv> {
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)) {
Expand All @@ -419,16 +434,16 @@ export function pageRoutes(): Hono<AppEnv> {
}

const html = resumeHtml({
markdown: resume.markdown,
parsed: resume.parsed,
markdown: shown.markdown,
parsed: shown.parsed,
title: resume.title,
});
if (format === 'html') {
return c.body(html, 200, { 'content-type': CONTENT_TYPE.html });
}

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)}"`,
Expand Down
18 changes: 17 additions & 1 deletion src/views/candidates.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
<div class="grid-2">
<article class="stack">
<div class="stack-sm">
Expand Down Expand Up @@ -215,6 +217,14 @@ export const CandidateDetail: FC<{
</li>
))}
</ul>
{contactRedacted && (
<p class="small muted">
<a href={`/login?next=${encodeURIComponent(`/candidates/${candidate.slug}`)}`}>
Sign in
</a>{' '}
to see how to reach {candidate.name}. An agent holding a token sees them too.
</p>
)}
</Card>
)}

Expand Down Expand Up @@ -249,6 +259,12 @@ export const CandidateDetail: FC<{
<p class="small muted">
The Markdown is the canonical document. <a href="/docs/openresume">The spec</a>.
</p>
{contactRedacted && (
<p class="small muted">
Called without a token it comes back with the contact block withheld and{' '}
<code>contactRedacted</code> set, the same as this page.
</p>
)}
</Card>

{!listed && (
Expand Down
Loading
Loading