0.7.0: a resume is public, the way to reach the person is not - #38
Merged
Conversation
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015xXMzJf85q87oeG3VEKHdJ
ralyodio
added a commit
that referenced
this pull request
Sep 9, 2026
0.7.0 was tagged and published to npm before #38 merged, so the release and the package named 0.7.0 contain the employer decision flow but not the contact gate. Two PRs hand-bumped to the same number and both merged cleanly, which is how a version stops identifying a build. No behaviour change. This is the number the contact gate ships under. Claude-Session: https://claude.ai/code/session_015xXMzJf85q87oeG3VEKHdJ Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Anyone could read a published resume and take the candidate's email address and phone number out of it. Walking
/candidatesand 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.
What counts as a channel
It 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:
hrefis a way to reach someone (mailto, tel, a profile) and is withheldSo
Location: LisbonandWork Authorization: EU Citizensurvive. The directory already prints the location on every card and filtering on it is the point, so withholding it there would be theatre.Saying so, rather than going quiet
The withheld bullets are replaced by
- **Contact**: shared with signed-in membersinstead of removed. A caller that cannot tell a redacted document from a resume with no contact details will report the second as the first. The JSON also carriescontactRedacted: trueso an agent does not have to guess, and the OpenAPI description andllms.txtboth say it.Redaction is Markdown in, Markdown out, and the routes re-parse the result rather than being handed a doctored parse, so the document and the parse of it can never disagree about what was withheld.
Every representation, not just the page
They all render the same Markdown, so they all go through one helper. Gating the page and forgetting the PDF is how the address stays public on a board that looks careful.
/candidates/:slug/api/v1/candidates/:slugcontactRedacted: trueresume.md,.html,.pdf,.docx/candidates,/candidates.md,/candidates/feedThe one thing that cannot be redacted is an original upload, since those bytes are whatever the candidate typeset. An anonymous caller asking for
.pdfor.docxno longer gets that shortcut and gets a copy generated from the redacted Markdown. Worth knowing: that path needs a PDF engine, and the Docker image installs weasyprint, so this is only a change on an instance running without one.The gate is "signed in", nothing more
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 now an account behind the read, which is the thing a scraper does not want to have.
Testing
161 pass, including a new end-to-end test against a real Postgres that publishes a resume with an address and a phone number and checks the page, the JSON,
resume.mdandresume.htmlall withhold them anonymously and all return them with a token. Six unit tests cover the redaction rule itself, including that a link under a## Linksheading is content and is left alone.🤖 Generated with Claude Code
https://claude.ai/code/session_015xXMzJf85q87oeG3VEKHdJ