மாற்றப் பதிவு
GitHubஒவ்வொரு குறிப்பிடத்தக்க மாற்றமும், வெளியீடு வாரியாக. இதே கோப்பு ரிப்போவில் இருக்கிறது மற்றும் அனைத்து 19 மொழிகளிலும் மொழிபெயர்க்கப்பட்டுள்ளது.
[1.241.0] — 2026-10-07
Hardening release from a 28-agent code review (~190 verified findings): the security, process-crash and data-loss classes are closed, plus a job map contributed by @bullitt186.
Added
- Job map (
#/map) — postings from the pipeline, the last scan and the tracker on an OpenStreetMap map, geocoded through Nominatim with an identifying User-Agent, a 1 req/s queue, back-off and an on-disk cache. Leaflet is served from this server (no CDN); the CSP only gains the tile origin inimg-src. Contributed by @bullitt186 (#381). - Request guard — the server refuses a
Hostit does not serve (DNS rebinding) and cross-site writes and side-effecting GETs (/api/stream/*,/api/run/*,/api/geocode). A reverse proxy that forwards a public name lists it inALLOWED_HOSTS; the deploy workflow writes that systemd drop-in from the public URL. - Coverage gate —
npm run test:coverage:gate(scripts/coverage-report.mjs) prints per-file line/branch coverage and fails below a ratcheting baseline.
Fixed
- A malformed request no longer stops the server. Express 4 ignored rejected async handlers, so a wrong-typed field in
/api/deep,/api/auto-pipeline,/api/career-plan,/api/trackeror a NUL byte in/api/statsexited the process; they are now JSON 400s, and any other rejection becomes a 500. - An aborted or failed scan no longer wipes the last results.
last-scan.jsonis kept when a scan is stopped or every source failed, and a single-company scan merges instead of replacing. One misconfiguredportals.ymlentry no longer aborts the whole scan. - Evaluations see the whole method. Mode files were cut at 16 KB, so
oferta.md(92 KB) lost blocks C–G; per-file caps are now 128 KB for modes and 64 KB for the CV, and a cut is reported. Empty or cut-off answers fail instead of writing an empty report. - SSRF: redirect hops are re-checked for private targets, every DNS answer is checked, the scan deadline covers the body,
*_BASE_URLkeys only go to the vendor’s host (Hermes/Ollama: loopback or LAN), pandoc runs with--sandbox. - Event-loop stalls: two quadratic regexes (HTML stripping, markdown sanitising) replaced with linear scanners.
- Data loss: deleting a pipeline row kept the other rows’ compensation;
$&in a URL corruptedpipeline.md; mock-interview, networking and deep-research saves overwrote earlier files; interview history kept the first 40 turns instead of the last. - LLM layer: auto-pipeline and deep research honour
LLM_PROVIDERand every provider through the shared dispatch, record usage and report truncation; Gemini evaluate/test no longer save a report; OpenAI usesmax_completion_tokens, and a Codex-onlyOPENAI_MODELis sent asgpt-5; the job description is fenced as untrusted data. - Report, deep and inline PDFs pass
--skip-fact-check(the CV fact check blocked any report with numbers); the profile’s page format is no longer overridden. - Scanners: quarantine is keyed by URL (fixing a URL retries it), within-run dedup by canonical URL, blank list entries no longer match everything, atomic writes,
skipped_*history rows are not “seen”. - Process control: the SIGKILL escalation never fired; batch runs kill their whole process group on disconnect.
- CI and release: CI now runs the changelog-parity, workflow and i18n gates; least-privilege workflow permissions; production secrets only under the
productionenvironment; the npm package now shipsdocs/help/. - Tests: no test touches the network or the real parent project any more (an explicit
CAREER_OPS_ROOTis always honoured); copied algorithms replaced by the production code.
Notes
- Tests 4056 → 4404, browser 118. Server coverage baseline: line 96.7 %, branch 86.3 % (
scripts/coverage-baseline.json). - Specifications now live in one folder,
docs/sdd/specs/; the program plan isdocs/sdd/PLAN.md, the invariant→gate matrixdocs/sdd/TRACEABILITY.md. - Operator note: behind a reverse proxy set
ALLOWED_HOSTS=<public host>; an Ollama/Hermes base URL on a public host is now refused. - Not in this release, deliberately (see
docs/sdd/PLAN.md): the sources-correctness pass (justjoin and nofluffjobs are dead upstream-side, ~40 sources still read a malformed 200 as an empty board) → v1.242.0; client, CSS and a11y review findings → v1.243.0; the#/scanredesign → v1.244.0; the Tamil locale → v1.245.0.
[1.240.0] — 2026-10-06
Parent parity — career-ops main @ 62905981 (VERSION 1.35.0, 177 upstream commits since b39931e). Six new sources — 103 → 109 (104 EN + 5 RU) — fixes mirrored into eight existing providers, and the checklist pipeline format, contributed by @bullitt186.
Added
- Six new sources — 103 → 109 (104 EN + 5 RU), 98 → 104 EN adapters.
- ADP Workforce Now (
workforcenow.adp.comwithcid+ccId) —$skippagination by rows actually returned; a response with neitherjobRequisitionsnormetathrows instead of reading as an empty board; opt-in detail enrichment (adpWorkforcenow.fetchDetails, default 25, cap 100). - Gupy (
employability-portal.gupy.io) — board-wide keyword sweep, newest-first with an early stop onsince_days;workplaceTypeis read as the singular pt-BR string so hybrid postings are not lost; confidential career pages are dropped. - JazzHR (
*.applytojob.com) — card parser with a bare-anchor fallback so a partial redesign still yields postings; posting links with zero parsed cards throw, a truly empty board returns[]. - Startup Jobs (
startup.jobs/feeds/jobs) — RSS, one request; a malformed, HTML or truncated body throws instead of returning[](the parent’s post-merge review fix,08fe5d06); CDATA and comments are masked before item matching. - Taleo (
<tenant>.taleo.net,tre.taleo.netrefused) — shell GET thensearchjobsPOST; a shell with no portal id throws as a private board; columns matched by heading. - UKG Pro / UltiPro (
recruiting[N].ultipro.{com,ca}) —Top=50+Skip,totalCountnever trusted as a stop condition, page cap 100 (clamped at 1500), opt-in detail pages parsed with a string-aware brace walk.
- ADP Workforce Now (
Fixed
- Pipeline in the parent’s checklist format — upstream career-ops writes
data/pipeline.mdas## Pending/## Processedsections of- [ ] url | …rows, which the UI read as 0 pending and wrote to by appending a fence at the end of the file, where the parent never looks.parsePipeline,addPipelineUrlandremovePipelineUrlnow read and write that shape (dedup against processed rows too; compensation as a labelednote: comp …segment); the fenced format is unchanged. Contributed by @bullitt186 (#380). - Country folded into location — Ashby (primary
address.postalAddress), Breezy (country.name) and Recruitee (flatlocation) append the country unless the name already carries it as a whole word, so a UK-primary, US-secondary remote role is no longer dropped by a location filter. - MokaHR — the
hire-r1.mokahr.comregional host is accepted (allowlist is exactlyapp.mokahr.com+hire-r1.mokahr.com), API POST and job links use the board’s own host, and an in-bandsuccess:falsenow throws instead of reading as an empty board. - Avature —
article--jobscards parse (title from the header anchor, location from theicon-addressspan). - SAP SuccessFactors — a
/go/<Category>/<id>[/<offset>]/saved-search URL resolves to the tenant root; it used to hit…/tile-search-results/, which answers 200 with zero tiles. - Deutsche Bahn — db.jobs blanks any results page that holds a posting it cannot render; the walk now sorts by
pubExternalDate_tdtwith 1000 per page (5 pages max), stops on the raw hit-anchor count, and throws on a missing results header or unparsed posting links instead of returning a short board. - HigherEdJobs — an Incapsula challenge answered with HTTP 200 used to parse as zero items and read as an empty board; it now throws.
- A test read the real parent project on a developer machine —
tests/reports-list-cache.test.mjssetCAREER_OPS_ROOTto a temp directory with nocv.md/portals.yml, whichpaths.mjsignores, so locally it counted the parent’s reports (6 instead of 3) while CI, with no parent beside it, passed.
Notes
- Tests 3782 → 4056. New suites:
sources-adp-workforcenow,sources-gupy,sources-jazzhr,sources-startup-jobs,sources-taleo,sources-ultipro,sources-location-country-fold,sources-deutschebahn-blanked-page,sources-higheredjobs-challenge-page, plusparsers-pipeline-checklistfrom #380. - Parent: the fork was brought to upstream
main(144 → 0 commits behind) with the four ADR-0002 divergences verified intact; upstream’s revert of the CyrillicLOCATIONISH_REdid not come through. - Already shipped in v1.239.0, not repeated: AppliTrack, NEOGOV, Red Rover, SchoolSpring, OCC Mundial, Greenhouse embed boards, Workday
myworkdaysite.com. - Not ported, with reasons: trusted-proxy egress (
CAREER_OPS_TRUST_PROXY_EGRESS,_http.mjs/_ip-guard.mjs/_dns-cache.mjs) — web-ui scans through its ownhttp-json.mjstransport; thescan-history.tsvcolumn registry and language-aware requisition dedup — web-ui reads positions 1–12, which did not move, and dedups by URL; SmartRecruitersrequisitionId/language— no consumer in web-ui’s job shape; GemisoCountry→ country name — needs the parent’s 249-entry alpha-3 table, deferred; CLI-only changes (modes, updater, doctor, PDF/ATS payload, tracker scripts, Go dashboard, Singapore mode).
[1.239.4] — 2026-09-29
Patch from the v1.239.3 live regression.
Fixed
- Korean and Traditional Chinese evaluations reported a missing score summary. Those reports run to 10–11 thousand characters, and the summary comes last, so it fell past the 8192 output-token limit. Evaluations (and the auto-pipeline) now allow 16384 tokens, and when a provider still cuts an answer off, the first warning says so instead of listing missing blocks.
- The live check read a short German answer as not German (3 German stop words, no English ones). A list-shaped answer can hold few stop words; with no English ones at all, three are now enough.
- Tests 3778 → 3782.
Notes
- The deep-research and mode routes keep their 8192-token limit: their answers are not long enough to reach it in the live runs.
[1.239.3] — 2026-09-29
Patch from the v1.239.2 live regression.
Fixed
- Arabic evaluations still failed the A–G check after v1.239.2 fixed Japanese and Hindi. A masked heading outline (no report text in the log) showed why: the Arabic report writes the block letter in Arabic, in abjad order, whatever the prompt asks. The check now accepts أ ب ج د هـ و ز as A–G when it stands alone near the start of a heading, as it already accepted Cyrillic look-alikes; the same letters inside an Arabic word do not count.
- The live check prints that masked heading outline when an A–G check fails, so the next case can be diagnosed from the log.
- Tests 3776 → 3778.
Notes
- The v1.239.2 note said the check would not be loosened; for Arabic it now is, because three live runs showed the prompt alone does not hold the Latin letter.
[1.239.2] — 2026-09-29
Patch from the v1.239.1 live regression.
Fixed
- An Arabic evaluation translated its block letters and a Japanese one dropped the score summary. v1.239.1 restated only the language as the prompt’s last line. That line now also says the Latin letter A–G in each heading and the
---SCORE_SUMMARY---block (markers and keys in English) must not be translated, and asks for the language’s own script. - Tests 3775 → 3776.
Notes
- The report check is not loosened to accept translated letters or markers: the prompt keeps the format stable instead.
[1.239.1] — 2026-09-29
Patch from the v1.239.0 live regression.
Fixed
- Japanese and Ukrainian evaluations came back mostly in English. The language instruction sat at the top of the prompt, before tens of kilobytes of English project files and the job description. The prompt now restates the language as its last line.
- The live check reported a failed scan when the server was already running one (the hourly scan, retried right after the deploy). The server refusing a second scan is correct; the check now waits and retries, up to 30 minutes.
- Tests 3774 → 3775.
Notes
- The server’s single-flight scan is not relaxed: a second scan is still refused with
SCAN_BUSY. Only the check changed.
[1.239.0] — 2026-09-29
Parent parity with career-ops @ b39931e: five new sources and three mirrored fixes.
Added
- Five new sources (98 → 103; English adapters 93 → 98): AppliTrack (
applitrack.com/<district>), NEOGOV (SchoolJobs / GovernmentJobs), Red Rover (jobs.redroverk12.com/org/<id>) and SchoolSpring (<district>.schoolspring.com) for school and public-sector hiring, plus OCC Mundial, Mexico’s largest job board (explicitprovider: occwith search queries). Each is pinned to its host, HTTPS-only and refuses redirects. - Tests 3500 → 3774.
Fixed
- Greenhouse embed boards (
job-boards.greenhouse.io/embed/job_board?for=<board>) resolved to a board named “embed” and failed; the board now comes from?for=, and only on a real Greenhouse host. - Workday boards on
myworkdaysite.com(/recruiting/<tenant>/<site>) were not recognised; they now scan, with posting links on the same host. - A report file name with parentheses (
042-acme-(berlin)-….md) was cut at the first)in the tracker; the whole name is kept now.
Notes
- CLI-only parent changes are not ported — PDF and CV templates, the scaffolder, the batch runner, the career-profile import, ATS keyword coverage, interview-mode translations, the
piCLI host: web-ui does not run them. - Opening such a report from the tracker still needs the report route to accept parentheses in a file name; that route is not changed in this release.
[1.238.3] — 2026-09-27
Pages stay responsive while a scan runs, and remote QA logs no longer leak the production host.
Fixed
- The dashboard and reports took seconds to load, and timed out during a scan.
/api/reportsand/api/dashboardread and parsed every report on every request — about 3.7 s on the production server, blocking every other request; with a scan running, page loads hit 30-second timeouts. Parsed report headers are now cached per file and refreshed when the file changes, so a listing costs one file check per report (500 reports: 430 ms → 3 ms locally). - A remote QA finding could print part of the production hostname into a public log. GitHub masks a secret only where its full value appears, and an error message cut to 180 characters ended mid-host. Findings and whole reports are now redacted before they are cut.
- Every AI evaluation was flagged as malformed. The prompt never asked for the
---SCORE_SUMMARY---block the report check requires (the parent’s evaluation scripts do), and a translated report translated the word “Block”, so the check missed its sections. The prompt now asks for the summary and for the block letter in every heading, the check accepts a translated word before the letter, and the summary block is removed before the report reaches the page or is saved.
Added
- Tests 3488 → 3500.
Notes
- The scanner still runs inside the server process; this release removes the other big blocker but does not move the scan to a worker.
[1.238.2] — 2026-09-27
Fix for the 502s the production server still showed while the hourly scan ran.
Fixed
- Pages and assets returned 502 while a scan ran. Node closes an idle keep-alive connection after 5 s; Caddy reuses idle upstream connections for up to 2 minutes. While an in-process scan kept the server busy, Node closed sockets Caddy had just sent a request on, and Caddy answered 502 connection reset by peer (62 times during one scan, with the server itself healthy). The server now keeps idle connections for 125 s — longer than the proxy — so the proxy always closes first.
Added
deploy.yml→verifyshows why the proxy answered 502 (error kinds, addresses masked), 502s per minute since the last restart, and the hourly scan’s outcome.release.ymlcan create a missing tag when started by hand, so a release can be cut where tags cannot be pushed.- Tests 3486 → 3488.
Notes
- The scan still runs inside the server process and can keep it busy for seconds; moving it to a worker is not part of this patch.
[1.238.1] — 2026-09-27
Stability patch from the first live regression of the production server: one scan at a time, and site fixes.
Fixed
- Two scans at once crashed the server. Every
GET /api/stream/scanran the full scanner inside the server process with no guard; a scan started from the UI overlapped the hourly timer and the process died with JavaScript heap out of memory (twice, with 502s meanwhile). Scans are now one at a time: a second request gets an SSEerrorwith codeSCAN_BUSYand nothing starts. The deploy workflow also raises the server’s heap limit (heap_mb, default 448 MB). - cvstart.org
/helpand/changelogscrolled sideways on phones (and ja/zh/changelogon desktop) — long code and table-of-contents lines now wrap. - Hindi was missing from the sitemap’s language alternates; the site’s i18n check now compares the sitemap map with the locale registry.
- Help §19 told translators to add a key to “all 8” language files — there are 17.
Added
- Remote regression (
.github/workflows/remote-qa.yml): every route × every language on the deployed app (read-only), every page × language × phone/desktop on cvstart.org, every link in the READMEs and the wiki, and — on request — one real scan plus live AI calls in every language. - Server deploy workflow (
.github/workflows/deploy.yml):recon,deploy(with a byte-exact rollback) andverify(smoke test plus stability report). - Tests 3481 → 3486.
Notes
- The scanner’s own memory use is not reduced in this release: the guard stops two scans running at once and the heap drop-in raises the ceiling. Profiling a real 98-source scan needs the production network.
- The hourly
career-ops-scantimer is not changed;reconnow shows which commands it runs.
[1.238.0] — 2026-09-27
Parent parity — career-ops main @ 993085ce (VERSION 1.34.0, 182 commits since de2224a9). Four new sources, ten mirrored provider fixes, a scanner-wide SSRF gap closed, and a free title-fit hint on scan results.
Added
- Four new sources — 94 → 98 (93 EN + 5 RU), 89 → 93 EN adapters.
- Eploy (
provider: eploy) — reads the tenant’s/live-jobs.xmlsitemap on any branded host (HTTPS only; IP literals,localhost,*.local,*.internaland single-label hosts refused). A non-sitemap response (a login page) throws instead of passing as zero jobs. Detail pages are opt-in (eploy: { fetchDetails, detailLimit }). - HiringRoom (
*.hiringroom.com) — JSON-LD/portal/jobsfirst, legacy/jobscards as fallback;http://posting URLs in the payload are rebuilt on HTTPS. - PeopleSoft Candidate Gateway — detected by the
/psc/{site}/EMPLOYEE/HRMS/c/HRS_HRAM_FL.HRS_CG_SEARCH_FL.GBLpath on any branded host. It follows PeopleSoft’s same-origin session redirects itself (max 10 hops, every hop re-pinned to the tenant origin and checked against private addresses), replays the “load more” form, and marks a partial walk (peoplesoftIncomplete) rather than reporting it as complete. - PrevueAPS (
*.prevueaps.ca) — resolves the site id from the jobs page, then reads the JSON API.
- Eploy (
- Title-fit hint on
#/scanrows. Each result carries a coarse strong / related / weak fit chip comparing the posting title with the target roles inconfig/profile.yml(target_roles.primary+ archetype names). It is a free keyword estimate: it never filters, reorders or changes counts, and the tooltip says so. Computed when results are served, so a profile edit shows on the next refresh. Translated ×17. Mirrors parent #3261.
Fixed
- Security — the DNS-rebinding guard never ran on a real scan.
http-json.mjsonly checked resolved addresses when handed the bare globalfetch, but both scanners injectmakeTimeoutFetch(), so every scan skipped it. A source pointed at a hostname resolving to10.xor127.0.0.1was fetched. The guard now runs insidemakeTimeoutFetchitself, raced against the scan timeout so a stalled resolver cannot hang a scan. Found in the review of this release. - Security — IPv6 addresses embedding an IPv4 address were not treated as private.
http://[::ffff:127.0.0.1]/(which the URL parser rewrites to[::ffff:7f00:1]) passedisValidJobUrl. Both the dotted and hex forms of::ffff:a.b.c.d/::a.b.c.dare now classified by the embedded address. Parent_ip-guard.mjs. - Rippling moved to the v2 board API (
ats.rippling.com/api/v2/board/<slug>/jobs), with pagination (default 10 pages, max 50), a host lock on job links, and slug validation. A pasted API URL now yields its real slug instead ofapi. - Recruitee — multi-location offers list every place (
A · B),(Sample)demo postings and title-less rows are dropped, and an offer is kept when either of its two URLs is valid. - Pinpoint — seeded demo postings (the doubled demo video) are dropped.
- Telegram channels — a first line with several
|is no longer read asTitle | Company, and more role words are recognised. The fork’s Cyrillic-awareLOCATIONISH_REis untouched (ADR-0002). - Workday — a first-page 422/401/403 is probed once; Workday’s maintenance page or outage redirect marks the board dead (quarantined in strict mode). The CXS and detail requests now refuse redirects.
- BambooHR — a redirected
/careers/listis triaged through the public embed feed: empty means a live board with no openings, anything else is a dead board. - Heckler & Koch / Rheinmetall — a bare
heckler-koch.com/rheinmetall.comis rewritten towww.so the no-redirect transport still reaches it. Ashby refuses redirects. beesite paces pages with the cancellabledelay(). - Title filters fold accents on both sides (EN and RU scanners), so
Développeuris caught by adeveloppeurnegative. - Cooldown company matching understands non-Latin names and corporate forms (CJK/Korean markers, Turkish dotted İ). CJK names used to collapse to an empty key and never match.
location_filter.strict: truefails closed on an empty location when an allow or block list is set.- Liveness — new public-API checks for Greenhouse embeds (
gh_jid), Arbeitsagentur and We Work Remotely; SmartRecruiters now decides by the posting’sactiveflag. - PeopleSoft — a relative “load more” form action resolves beside the search page, not at the origin root.
Notes
- Tests 3210 → 3481. New suites:
sources-eploy,sources-hiringroom,sources-peoplesoft,sources-prevueaps,title-fit,fetch-timeout-dns-guard,cooldown-company-match-unicode,liveness-api-more-rungs,ru-scanner-title-accent-fold. - Not ported, with reasons: location-filter Unicode word edges (web-ui’s filter is substring-based, so there is no edge to fix — a deliberate full port of the parent’s location filter is future work); same-title requisition dedup (web-ui dedups by URL only); aggregator repost warnings, blacklist domain scope, unverified-zero receipts and
scan-runs.tsvmigration (web-ui has none of those surfaces); Workday facet-split and truncation tags (web-ui fetches one page per board); BambooHRenrichDate(no date-enrichment hook); the parent’s_http.mjssleep refactor across ~20 providers (web-ui already uses a shared cancellabledelay); updater, doctor, PDF, tracker/merge-tracker,validate-profile,discover-new-companies,web/Next.js app, GitHub bots — CLI-only or relayed. - Kept deliberately different from the parent: Heckler & Koch / Rheinmetall stay HTTPS-only (no
http→httpsupgrade); a dead Workday board in a normal scan still returns[]rather than failing the scan.
[1.237.1] — 2026-09-22
Patch from an external QA pass on v1.237.0. The Jobstreet fix was confirmed from outside for the first time: on the AU, NZ, HK and MY hosts the old /id/job/<id> form returns a clean 404 and the new /job/<id> reaches a real Cloudflare-guarded route; on id.jobstreet.com the reverse holds. The lost links were real. Two defects were found, both in what v1.237.0 itself added.
Fixed
- The book link’s label was localized but its target was not. All 17 SPA locales and all 17 cvstart.org locales pointed the Agentic Coding Design Patterns footer link at the English edition, while the one translated label — Russian, «Паттерны агентного кодинга» — promised a Russian book. A new
data-i18n-hrefapplier (same shape asdata-i18n-title, restricted to absolutehttps://values) and afooter.patternsUrlkey sendruto the Russian edition and every other locale to the English one. The regression test asserts the invariant in both directions: a translated title implies a localized URL. - SEEK is migrating hosts, and the transport refuses redirects.
www.seek.com.au→au.seek.comandwww.seek.co.nz→nz.seek.com: the detail pages already 301, and both host pairs serve the v5 search API at 200 today. Becausehttp-json.mjssetsredirect:'error'(an SSRF defence worth keeping), the day the API path starts redirecting an AU/NZ entry on the old host would fail — and with only thewww.hosts allowlisted nobody could migrate.au.seek.comandnz.seek.comare now inALLOWED_JOBSTREET_HOSTS; the old hosts stay, so existing config and already-built links keep working. Look-alikes (au.seek.com.evil.com,au-seek.com) are still refused. - Wiki: the Agentic Practices row in the Home documentation map was the only one of twelve not escaping
|inside[[…|…]], so GitHub cut the cell — dead link, description hidden. One character.
Notes
- Counts unchanged at 94 sources (89 EN + 5 RU), 89 adapters. Tests 3201 → 3210 (+5 footer-link locale, +4 SEEK hosts).
- Not closable from outside, recorded rather than claimed: Oracle Cloud (Akamai blocked both REST probes), the Personio pin and
appendWorkType(need aportals.ymlentry, and the UI has no delete for one), and the liveness string cases (no controllable page; 40 live probes gave 12 live, 28 inconclusive, 0 false expired). - Help ×17 now list both host forms for
AU-MainandNZ-Main. Heading structure unchanged at 32 H2 / 122 H3.
[1.237.0] — 2026-09-21
Parent parity — career-ops main @ 93c4302b (VERSION 1.33.0, 75 commits pulled from career-ops-hq/main). No new sources this time: four mirrored fixes, and two of them were losing postings here silently.
Fixed
- Jobstreet / SEEK — every Australian, New Zealand, Singapore, Malaysian and Hong Kong posting linked to a dead page.
parseJobstreetItembuilt the detail URL as${baseUrl}/id/job/${id}unconditionally. The/id/segment is not a path — it is the Indonesian locale prefix, and it only exists on the Indonesian hosts. Every other SEEK-platform host answers 404 on/id/job/<id>. web-ui had already addedwww.seek.com.au,www.seek.co.nz,sg.jobstreet.com,my.jobstreet.comandhk.jobsdb.comtoALLOWED_JOBSTREET_HOSTS, so the scan happily returned those postings — each one pointing at a 404. The path is now keyed on the host:ID_LOCALE_HOSTS(id.jobstreet.com,www.jobstreet.co.id,jobstreet.co.id) keeps/id/job/, everything else gets/job/, and an unparseable origin falls through to the common path instead of throwing. Two of our own tests had encoded the bug — they assertedhk.jobsdb.com/id/job/…andwww.jobstreet.com/id/job/…— and were corrected. - Oracle Cloud — a short page mid-list ended the walk and dropped the tail of the board. The stop condition was
listLen === 0 || listLen < PAGE_SIZE, but ORC serves fewer rows than the limit in the middle of a list because some rows are filtered server-side. Measured upstream on American Express:TotalJobsCount454, pages of 200 / 199 / 54 — the 199 was read as the end and the last 54 postings, 12% of the board, were never fetched. Now an empty page always ends the walk; when the tenant reports a total, paging continues untiloffset + PAGE_SIZE >= total; only a tenant with no total to check against ends on a short page. This matches the wider convention that an API “may return fewer results than the number requested … even if not at the end of the collection” (Google AIP-158).hasMoreremains deliberately ignored — some tenants returnhasMore:falseon every page of a 7000-job board. With short pages no longer ending the walk,MAX_PAGESis now the only backstop against a tenant reporting a bogus total, so it is covered by its own test. - Liveness — “This role is closed” was not recognised, and a false expiry is permanent. The pattern was
/this job (listing )?is closed/i, which misses the equally commonroleandpositionphrasings; on one board upstream measured 111 of 111 uncertain postings using “role”. Widened to/this (?:job|role|position)(?: listing)? is closed\b(?!-)/i. The trailing\b(?!-)is a compound-adjective guard:\balone rejectsclosedownbut still matches real prose like “This role is closed-loop control of the platform”, and the lookahead is what stops it. - Liveness —
job expirednow sits in a soft tier that cannot beat a visible Apply button.HARD_EXPIRED_PATTERNSis checked before the apply-control test, so anything placed there wins over a live posting./\bjob expired\b/ifalse-fires on four real live-page shapes — a “Similar jobs” carousel entry, a “Hide job expired” filter chip, a footer FAQ, and the closed-loop prose above — because the classifier receives the whole page’s text. It now lives inSOFT_EXPIRED_PATTERNS, checked after the apply control and before the listing-page heuristic, returningexpired_body_soft; a bare “JOB EXPIRED” banner with no apply control still expires. This direction matters because a falseexpiredis written to scan history asskipped_expiredand then dedup-filters a real job out of every later scan, indefinitely, unlessscan_history.recheck_after_daysis set.
Added
- Personio — an explicit
personio: <slug>pins the tenant. Many companies embed their Personio board as an iframe on a branded careers page, socareers_urlpoints at the company’s own domain while the feed lives at<slug>.jobs.personio.de; without a pin those boards resolve to nothing. The slug is charset-restricted by an anchored allowlist (/^[a-z0-9][a-z0-9-]{0,62}$/i) and the resulting URL is still routed through the existingnew URL()parse, HTTPS check andPERSONIO_HOST_REhost gate — the pin is never a way to reach another host. A slug carrying.,/,@,:,.., whitespace, a leading-, or exceeding the 63-character DNS label limit falls through to today’sapi/careers_urlbehaviour. Unlike the parent, web-ui’s host regex is case-sensitive, so the pin is normalised throughnew URL()—AcmeGroupresolves toacmegroup.jobs.personio.derather than building a host its own allowlist would then reject. - Jobstreet — opt-in
appendWorkTypesuffixes the title with the listing’s work types, e.g.Strategy Consultant [Part time], so title filters and triage can see employment type at scan time. Off by default; a missing or emptyworkTypesis a no-op.
Notes
- Source and adapter counts are unchanged: 94 sources (89 EN + 5 RU), 89 EN adapters. This release moves only the test count, 3164 → 3201 (+13 jobstreet, +7 Oracle Cloud, +9 liveness, +8 Personio).
- The parent deleted
providers/telegram.mjs, consolidating ontelegram-channel. web-ui registers both as user-facing sources, so the deletion is not followed: it would drop the registry from 94 to 93 and silently break anyone who hastelegramconfigured. Revisit only as a deliberate, announced removal. jd-skill-gap.mjsgained an uncontracted “what we are looking for” heading form and a bold-marker strip. web-ui relays that script rather than mirroring it, so the fix arrives with the deployed parent and needs no change here.- Not ported, with reasons:
lib/latex-escape.mjs,browser-extract.mjs,openai-eval.mjs,update-system.mjsand the scaffolder (web-ui neither mirrors nor shells into them); the Go dashboard TUI andweb/config form (separate surfaces);doctor.mjsandanalyze-patterns.mjs(read-only relays whose fail-soft absorbs the change). - The fork’s Cyrillic
\p{L}divergence inproviders/telegram-channel.mjssurvived the upstream merge intact and was verified byte-for-byte against a pre-merge copy.
[1.236.0] — 2026-09-18
Parent parity — career-ops main @ 6a9c84c (VERSION 1.33.0, 128 commits). Two new sources, and three mirrored fixes — two of which were losing postings here, silently.
Added
Two scanner sources, 92 → 94 (89 EN + 5 RU). Python.org Jobs (pythonorg) reads the board’s public feed; employer attribution is mandatory, so a row whose title names no employer is dropped rather than backfilled with the portal entry’s name. Generalist World (generalist-world) scrapes the board’s cards and fails loud on markup it cannot parse — a card shape that changed throws instead of reading as an empty board, which is the failure mode that makes a scraped source untrustworthy. Both appear in the #/scan Source filter, both are host-pinned (anchored regex, HTTPS-only), and both are driven end-to-end by fake-transport suites (43 and 36 cases).
Fixed
A Workday role open in several cities was dropped by a location filter. Workday’s list endpoint answers a multi-location posting with a count where every other posting carries a place: "53 Locations". That is not a location, and buildLocationFilter matches by case-insensitive substring — so a role open in Austin and 52 other cities matched no allow: [austin] entry and was discarded, while the same role listed singly passed. Measured upstream across three tenants: 53 of 291 postings were placeholders, so this is the ordinary case, not an edge one. The posting’s real places exist only in the CXS detail document, so each placeholder now costs one extra GET against it, capped at 200 per entry — past the cap the placeholder is left visible rather than faked, because an unresolved posting must never read as a resolved one. A failed or placeless detail leaves the posting exactly as the list returned it: a visible "53 Locations" is honest about what could not be resolved, whereas a blanked location would silently pass every filter. The same GET also carries startDate, so an enriched posting is dated exactly instead of by the list endpoint’s relative prose ("Posted 30+ Days Ago" → undefined); anything that is not a bare YYYY-MM-DD is left alone rather than run through Date.parse, whose non-ISO fallback is implementation-defined.
A stated level no longer collapses two requisitions into one repost. "Insurance Specialist II" is the employer’s own statement that this requisition is not its sibling — a different pay band, scope and req number under one base title. The tokenizer could not see it: w.length > 3 drops every roman numeral up to VIII and every single digit, so "Insurance Specialist" and "Insurance Specialist II" tokenized identically, scored a perfect Jaccard ratio, and the repost detector flagged the second as a re-listing of the first. web-ui’s role-matcher.mjs had no level handling at all, so this lands the parent’s whole mechanism: levels are extracted with roman and arabic folded onto one number ("Nurse II" and "Nurse 2" are the same statement — and that pair wrongly differed before), two stated levels must agree, and a level on one side alone is treated as a loose rewrite unless each title also carries a word the other lacks, which is two disagreements rather than a decoration.
An Avature board pinned to a filter walked the whole global board anyway. Avature’s search UI generates opaque facet params (?42386=[812132] is a country filter), and an operator who pins a narrowed board in portals.yml expects the scan to walk that. resolveSearch rebuilt the URL as ${origin}${path} and dropped the query string outright — so every request, the first one included, walked the tenant’s entire board: more pages, more requests, and postings the operator had deliberately filtered out. Facets now ride every paginated request, with our own jobOffset applied last so a stray same-named facet can never hijack pagination.
Security
devalue 5.8.1 → 5.9.2 (transitively via astro), closing the one open Dependabot alert — a denial of service on malformed input. Site-only dependency; no runtime code changed.
Notes
Not ported, each with the reason. The parent gave lever a 30s and manfred a 25s timeout against its shared 10s default — a no-port: web-ui’s scan transport already allows 60s (DEFAULT_SCAN_TIMEOUT_MS), so both boards have more headroom here than the fix grants there. iCIMS’ doubled careers- prefix cannot occur here either: web-ui takes the tenant origin straight from api/careers_url and never constructs a host from a tenant name; its new fallback-host walk is a feature for the parent’s looser config, not a defect fix. deutschebahn and manfred gained retry-on-transient-abort, which web-ui’s 60s ceiling makes far less pressing — noted rather than ported, so a future long-walk timeout has a known first thing to try. Everything else in the 128-commit delta is CLI, verifier, dashboard or web/ (the parent’s own frontend): liveness-browser, check-jd-archive, verify-ats/verify-portals/verify-cv-facts, update-system, set-status, audit-portals, discover-ats, scaffolder, test-all, plugin manifests.
Fork divergences re-verified after the pull: the Cyrillic \p{L} fix in providers/telegram-channel.mjs, the fork’s own providers/telegram.mjs, hermes in web/src/lib/clis.ts. Upstream career-ops-hq/main is fully merged (0 commits missing). 3066 → 3164 tests; help now states 94 adapters (89 EN + 5 RU) in all 17 bundles, and the heading structure is unchanged at 32 H2 / 122 H3.
[1.235.0] — 2026-09-16
Parent parity — career-ops main @ 68e6b94 (VERSION 1.33.0, 42 commits). Two mirrored fixes; one of them was losing MokaHR postings here, silently.
Fixed
A second job from the same MokaHR board vanished instead of being added. url-key.mjs reduces a posting URL to a comparison key, and it dropped the fragment unconditionally — reasonable, since almost every fragment is presentation-only (#apply, #section-2). MokaHR is the exception in our own registry: its postings are …/social-recruitment/{tenant}/{id}#/job/{n}, where every job on a tenant shares the path and the posting id exists only in the fragment. So all of them normalized to one key. Measured before the fix: paste two different MokaHR jobs into the pipeline and you end with one line — no error, no warning, nothing to notice; the scanner’s freshness filter (en-scanner.mjs) reads the same key, so once any job from that tenant was in scan-history, every other one was discarded as “already seen”. normalizeUrl now promotes a recognized #/job/{id} or #/jobs/{id} route into a comparison-only query key before the fragment is dropped — MokaHR keeps its established mokahr_job_id name so previously-written keys stay comparable, any other host gets an internal _career_ops_fragment_job_id. The promoted id is appended, not set: a URL already carrying that param holds a different posting’s id, and overwriting it would merge exactly the two things the key exists to keep apart. Every unrecognized fragment still collapses, which is the half that was already right.
accept-encoding is now pinned on every source request. Left unset, Node negotiates whatever its undici build offers; from Node ≥ 23 that includes zstd, and amazon.jobs’ zstd response comes back truncated at exactly 1024 bytes with a 200 status — so the failure reads as Unterminated string in JSON at position 1024, like a malformed API rather than a transport bug. Every request now sends gzip, deflate, br. On our runtimes this is preventive, not a repair: Node 18/20/22 — the CI matrix and the server (22.22.1) — send gzip, deflate, verified directly rather than assumed, so nothing was truncated here; what changes today is that br is offered, which those versions do not request by default but decode correctly. The default is applied case-insensitively, which is the part worth reviewing: header names are case-insensitive and fetch joins two same-named entries into one comma-separated value, so a caller passing Accept-Encoding: identity would otherwise have been sent gzip, deflate, br, identity.
Notes
Headers stay a plain object at the fetchImpl boundary, where the parent hands fetch a Headers instance. That is a deliberate divergence, not an oversight: web-ui’s fetch helpers take an injectable fetchImpl, and 45 source suites read opts.headers['User-Agent'] off what they capture — a Headers instance has no bracket access, so the faithful port is of the behaviour (case-insensitive override, caller wins) rather than of the implementation. The caller’s object is copied, never mutated, since several sources share one header constant. The parent’s companion fix — applying a default user-agent case-insensitively — is a no-port: web-ui injects no default UA at all, each source sets the one its board requires, so there is no default for a caller’s User-Agent to collide with.
Two new suites and three new cases pin the pair: tests/http-accept-encoding.test.mjs (the exact pinned value, each of the four fetch helpers, and a caller override under three capitalizations — asserting a single accept-encoding key survives, which is what the merge bug produced two of) and tests/parsers-pipeline-hash-route.test.mjs, which works the user-visible seam rather than the key: two MokaHR jobs must both reach the pipeline, a re-paste of the same one under four spellings must still collapse to one line, and a cosmetic fragment must not become an identity. All were confirmed failing first. The pin itself lives at the transport, not only in those helpers: 25 of the 92 sources call the injected fetchImpl directly and never pass through them, so makeTimeoutFetch — the wrapper both scanners actually inject — applies it, and that is the one chokepoint every source shares. (The parent needs no equivalent: providers/_http.mjs is its only transport.) A structural guard now fails CI if a new source calls fetch directly instead of taking the transport it was handed, and a behavioural case drives greenhouse, one of the 25, through the wrapper to prove the header reaches the wire.
Not ported, with the reason: liveness-browser.mjs (headed-Playwright reuse guard — web-ui does not drive a headed browser), check-jd-archive.mjs (no web-ui relay), test-all.mjs (parent’s harness; the fork’s vpFixtureEnv divergence is intact), web/ (the parent’s own frontend, not mirrored), plugin manifests and SIGNATURES.md (repository-only). batch/batch-runner.sh (ampersands escaped before sed interpolation) and jd-skill-gap.mjs (resolves cv.md against the data root) are relayed read-only — web-ui shells into the parent’s copies, so both land with no code change here, and the jd-skill-gap one makes the /api/jds/:id/skill-gap relay more correct: cv.md now resolves from the data root instead of whatever directory the process was spawned in. Fork divergences re-verified after the pull: the Cyrillic \p{L} fix in providers/telegram-channel.mjs, the fork’s own providers/telegram.mjs, hermes in web/src/lib/clis.ts. Sources unchanged at 92 (87 EN + 5 RU), so help is untouched. 3042 → 3066 tests.
[1.234.0] — 2026-09-15
Parent parity — career-ops main @ 56cce8f (VERSION still 1.32.0, 58 commits past 1.32.0). One mirrored fix across 15 sources, plus a guard so it cannot come back.
Fixed
A single malformed job id no longer empties the whole page. encodeURIComponent throws URIError on a lone UTF-16 surrogate, and a JSON payload can carry one — JSON.parse('"\\uD800"') keeps it. Fifteen sources built each posting’s URL from a host-controlled id or slug inside the parse loop, so one bad posting aborted the loop and lost every job on that page; nothing was logged as a source failure because the exception unwound before the per-company catch had anything to report. The parent fixed it in providers/_safe-url.mjs (safeEncodeURIComponent → null on URIError, rethrow anything else) and routed each loop’s id through it, dropping just the one posting on null. That helper is now server/lib/sources/_safe-url.mjs, and the same change is applied to the thirteen mirrored sources — alibaba, arbeitsagentur, bamboohr, feishu-jobs, garena, jibeapply, manfred, meituan, mokahr, phenom, thehub, tkms, vdab — and to two web-ui-only sources the new guard surfaced on its first run, jobstreet and trudvsem, which had the same loop shape and the same latent throw. Scope is deliberate: only API-sourced ids go through the helper. Config-derived values keep their strict encode and fail loud — garena’s office (a portals.yml segment) stays on the throwing urlSegment, while its API id gets a null-returning idUrlSegment; csod’s corpName and 4dayweek’s SLUG_RE-validated slug are allowlisted by name with the reason.
rheinmetall’s title fallback could throw on a malformed href. When a card has no headline the title is reconstructed from the URL slug via decodeURIComponent, which throws on a bad percent-sequence in the scraped href — the same one-bad-row-kills-the-page shape, on the decode side. It now decodes defensively and falls back to the raw slug.
The sources registry skips _-prefixed files. Auto-discovery imported every .mjs in server/lib/sources/ except itself and warned on anything without export const meta — so the helper produced a boot-time warning. The parent’s convention is adopted: a leading underscore means not a source; such files are neither imported nor warned about. An undeclared file without meta still warns, and the discovery test now asserts both halves (confirmed red against the old filter). The site build’s sync-assets.mjs counts adapter files on disk the same way, so the helper no longer trips its silent-drop guard.
Notes
tests/sources-url-encoding-surrogate.test.mjs mirrors the parent’s suite in three parts: the helper’s contract (a paired surrogate — an ordinary emoji — still encodes; a throw from the value’s own toString is the caller’s bug and propagates), per-source behaviour for all fifteen (one bad id + one clean id → no throw, clean kept, bad dropped, each driven through the source’s exported pure parser or, for thehub, its fetcher with a fake transport), and a source-level guard: every converted file must import the helper and call it, no file may reference it without importing it, and no unreviewed file may build a job URL with a bare encodeURIComponent — a new source with the old shape fails CI with the offending line number. All fifteen per-source cases were confirmed failing before the port.
Everything else in the parent delta is not ported, with the reason: batch/batch-runner.sh --cli is a CLI-only flag (web-ui does not shell into the batch runner); invite-match.mjs now resolves set-status.mjs from the code root — web-ui does not relay it; company-funded.mjs gained the same safe-url change internally and its --json output shape, which /api/company-funded parses, is unchanged; test-all.mjs moved a timeout budget only (the fork’s vpFixtureEnv divergence is intact); web/ is the parent’s own frontend and is not mirrored; AGENTS.md/SIGNATURES.md are repository-only. Fork divergences re-verified after the pull: the Cyrillic \p{L} fix in providers/telegram-channel.mjs, the fork’s own providers/telegram.mjs, hermes in web/src/lib/clis.ts. Source count unchanged at 92 (87 EN + 5 RU), so help is untouched. 3022 → 3042 tests.
[1.233.2] — 2026-09-10
Fixed — two high-severity CodeQL alerts on the #/config save path. No behaviour changes.
Fixed
Two high-severity CodeQL js/remote-property-injection alerts, closed by making an existing invariant structural rather than asserted. POST /api/config copies saved values into process.env so no restart is needed, and that loop iterated Object.entries(safe) — a map built from the request body — with an explicit __proto__ / constructor / prototype guard inside it. The code was safe: safe is populated twenty lines earlier by walking KNOWN_KEYS and copying only those, so the property name was never user-controlled. But read on its own the loop says assign to a property whose name came out of an object built from the request body, and CodeQL cannot follow the constraint across two loops. It now iterates the constant array directly, looking each key up in safe, so the property name is provably a module-level literal. The prototype guard went with it — a hardcoded list of SCREAMING_SNAKE names cannot contain one, and a runtime check against an impossible case is noise. Behaviour is identical; the alerts were dated 2026-09-09 only because v1.232/v1.233 shifted the line numbers, not because the loop was new.
Notes
Dismissing the two as false positives was the alternative, and the repository has a documented practice for exactly that. Fixing was preferable here: an assertion in a comment is worth less than a shape the analyser — and the next reader — can verify without following a constraint across twenty lines.
The test that pinned the old guard was a grep of the source text for the literal k === '__proto__' || … expression, which proves a string is present, not that the endpoint is safe. It is replaced by two stronger checks: a structural one asserting the loop reaches process.env[k] by iterating KNOWN_KEYS (and that it does not iterate the request-derived map), confirmed to fail against the old shape; and a behavioural one in config-endpoint.test.mjs that posts __proto__, constructor, prototype and an unlisted name as raw JSON — an object literal would not do, since { __proto__: … } in source sets the prototype instead of creating an own property, so JSON.stringify would have sent {} and the test would have proven nothing. 3021 → 3022 tests.
[1.233.1] — 2026-09-10
Fixed — the guide never described the control v1.233.0 added, and the same section still carried instructions that stopped being true 200 releases ago.
Fixed
DOC-1 — the control v1.233.0 introduced was not described anywhere in the guide. Every dropdown now leads with Use the default (…), which expresses a state the UI previously had no way to show: the key is not set. The difference from picking the same value out of the list below is invisible and consequential — Use the default (llama3.2) writes nothing and keeps handing you the project’s default as it moves, while choosing llama3.2 explicitly writes OLLAMA_MODEL=llama3.2 and leaves you on the old value, silently, the next time that default changes. That is precisely the drift CONFIG-2 and CONFIG-3 were about: the mechanism was repaired in code, and there was nowhere to read about it. Three bullets now sit in the existing ### Behavior list across all 17 bundles, covering the entry itself, the rule that a field you never touch is never written, and the toast counting removals as well as writes. The heading gates could not have caught this — they count H2/H3, and coverage is not a count.
DOC-2 — a row in the ### Recognized keys table named no key, and the one concrete instruction it gave was wrong. The Key cell held (server uses default UA) — a parenthesised note where a key name belongs — describing HH_USER_AGENT, which v1.19.0 removed from KNOWN_KEYS and from the UI. The row then told the reader to register an application at dev.hh.ru and supply its UA string, which has bought nothing since v1.65.0, when the hh.ru adapter switched to scraping the public search site with a browser User-Agent of its own. Two further mentions were equally stale: an Optional checks bullet describing a health row deleted in v1.28.1, and a troubleshooting row repeating the dev.hh.ru advice. All three are gone from all 17 bundles. The troubleshooting row is rewritten rather than deleted, because half of it is still true — since July 2026 hh.ru serves HTTP 451 to non-Russian IPs, so a Russian residential address genuinely is required; what was wrong was the registration step and the reference to a key that does not exist.
The keys table now says what it is. ### Recognized keys lists 6 of the 45 keys while its heading promises a registry; the provider families are covered in prose elsewhere in the same section. One sentence ×17 now states that it is a selection and points at #/config for the complete, grouped list. No keys were added — this closes the ambiguity rather than expanding the table.
Notes
tests/help-recognized-keys.test.mjs is the gate that was missing. It checks the first column of every help table that actually names config keys against the live KNOWN_KEYS, fails on any bundle still mentioning dev.hh.ru, and fails on any bundle that does not document the Use the default entry. Scoping matters: several bundles carry other tables whose first column is a backticked uppercase word — PDF in the export matrix — so a table qualifies only when at least one of its own rows names a real key. Confirmed on the pre-fix bundles: all three cases red. 3018 → 3021 tests; help stays 32 H2 / 122 H3 in all 17, because everything landed inside existing headings.
[1.233.0] — 2026-09-10
Fixed — three findings in the #/config save path. CONFIG-3 was caused by v1.232.1’s own fix.
Fixed
CONFIG-3 — a deliberate choice that happened to equal the seeded value was discarded as “untouched”. Caused by v1.232.1’s own fix, whose comment claimed the code could “tell a display default apart from a user who deliberately chose that same value”. It could not. initial conflates two origins: seeded from .env (equal means the field was not edited — correct to skip) and seeded from defaultValue (equal is ambiguous — never opened, or opened and agreed). With HOST absent the control shows 127.0.0.1; typing that exact value and saving posted {} and left the key absent. The only route to pinning it was to enter a wrong value, save, then set the right one back — a dead end nobody would find. Nothing misbehaved, since an absent key and a key equal to the default resolve identically, but the UI showed a state the file did not contain and pressing Save again never reconciled them. Fixed by recording seededFromFile beside initial: where the seed came from the file nothing changes, and where it came from defaultValue, dirty becomes a valid second signal. dirty cannot be the general basis — it fires on type-and-revert — but here it is the only remaining evidence, and it costs at most the one field the user was working in, never CONFIG-2’s eighteen, for which dirty is empty because nobody opened them.
Removing a key reported “· 0”. updateEnvFile only ever returned what it wrote, so a deletion had nothing to count and the toast read Settings saved · 0 — which a user reasonably takes as “nothing happened” while the key had in fact been removed. POST /api/config now answers with removed as well, computed against the pre-write snapshot so it names keys that genuinely were present rather than every key the request asked to clear, and the toast counts both.
A select could be set but never cleared. None of the 18 dropdowns offered an empty option, so the form could not send '' for one — text fields can simply be emptied, dropdowns could not. Together with CONFIG-3 that closed a loop for an unset select: it could not be pinned, because its value equalled the seed, and once pinned it could not be released. Every select now leads with an explicit “Use the default (…)” entry, and an unset key selects that instead of the default value. v1.57.1’s intent survives — the label still names the value the server will use — but it now says so rather than impersonating a choice, which is what made CONFIG-3 possible in the first place. select-remote needed care: the live OpenRouter catalogue calls replaceChildren, which wiped the entry, so it is re-created there too. One new i18n key, config.useDefault, across all 17 dictionaries.
Notes
Checked during this pass and deliberately left alone, so the record is complete. lang in every POST body is a literal allowlist entry, not a prefix rule — verified by behaviour: lang → 200 while langx, lang_evil and LANG all → 400, exactly like any unknown key. facts.json is absent from the deployed site by design — 22 files import it at build time and the values are inlined into the HTML, so the version token in the page is what to check, not the file; §2.3 of the v1.231.5 prompt was misleading on this. /api/cv/import treating an extensionless upload as text is documented and deliberate, so a clipboard save without an extension still works; the route converts and returns, never writes or executes, and .exe/.zip are refused explicitly.
Unit baseline unchanged at 3018 — every fix is in the client or in one route’s response shape. The gates are browser cases: tests/playwright-config-save-scope.mjs grows from six to eleven (111 → 116 browser tests), and the five new ones were confirmed failing against the old code first. One of them exists purely to keep CONFIG-2 from returning: an untouched Save must still post nothing.
[1.232.1] — 2026-09-10
Fixed — Save on #/config wrote fields the user never touched. Not a v1.232.0 regression; it is older.
Fixed
CONFIG-2 — Save on #/config wrote fields the user never touched. Two individually sound decisions combined into a write: v1.57.1 seeds an unset control with its defaultValue so the field shows the value the server will really use (correct, and unchanged), while save() consulted dirty for secrets only and sent every other field regardless. So the value put there for DISPLAY travelled to the server as if the user had chosen it. Measured on a fixture with two keys set, changing one dropdown posted 28 keys and pinned 18 the user had never opened — each its own default; with nothing changed at all, Save still posted 27. Nothing behaves differently on the day, because the written values are the defaults. What changes is the STATUS of each setting: from unset, follow the project default to pinned in .env. These curated lists move between releases — v1.232.0’s own notes cite gemini-2.0-flash dropping out — so the next time a default changes, every user who ever pressed Save is silently left behind on the old one. It is CONFIG-1’s shape again: that wrote nonsense, this writes something plausible, which is exactly what makes it harder to notice.
Notes
The comparison basis is the part worth reviewing. Comparing against the loaded cfg.values — the obvious fix, and the one first tried — is not enough: an unset select shows its defaultValue while the stored value is '', so the two differ honestly and all seventeen model dropdowns are still written. It cut the posted body from 27 keys to 19, not to one. Save now compares against what each control was seeded with, which is the only expression of “untouched” that separates a value placed there for display from a user who deliberately picked that same value. dirty is not the basis either — it fires when someone types a value and puts the original back.
Also fixed: the toast’s key count lived inside t()’s fallback argument, and config.saved exists in all 17 dictionaries — so the number never rendered and a Save of twelve keys looked identical to a Save of one. It now reads Settings saved · 1.
Nothing else moved. The v1.57.1 prefill stays, secrets are still sent only when touched, and the server-side grandfathering from v1.232.0 stays as the guard for direct API callers, who never go through the form. Test baseline is unchanged at 3018 because the fix is client-side; its gate is tests/playwright-config-save-scope.mjs, six browser cases now registered in test:e2e:browser (105 → 111), all six confirmed failing against the old code first.
[1.232.0] — 2026-09-09
Fixed — two defects from a browser QA pass, neither of them from v1.231.5.
Fixed
PROFILE-1 — #/profile scrolled the whole page sideways. Each profile field is a .card in a .card-row grid of repeat(auto-fit, minmax(220px, 1fr)), and the value was an anonymous <div> carrying only inline font styles — so its computed overflow-wrap stayed normal. A LinkedIn vanity URL offers CSS no legal break point, which makes the element’s min-content width the entire string; grid items default to min-width: auto and refuse to shrink below that, so the card burst its track, then the row, the main column and finally the document. Measured on a fixture profile, cards overflowed at 768, 1024, 1280 and 1440 px, and at 1280 px it reached the page as 23 px of real horizontal scroll. Width-dependent, which is why it survived so long: at ≤ 420 px the media query drops to one column wide enough to fit, and between 768 and 1024 px the text merely spills out of the card without moving the document. Fixed with a shared .card-value class carrying overflow-wrap: anywhere, plus .card-row > * { min-width: 0 } as a backstop. anywhere, not break-word — only anywhere is counted when the browser computes min-content, so break-word would have wrapped the text and left the track exactly as wide. dashboard.js now uses the same class; health.js already carried word-break: break-all and needed nothing.
CONFIG-1 — POST /api/config accepted any value for a select field. {"LLM_PROVIDER":"not-a-provider"} returned 200 and was written into the user’s .env. Nothing errored afterwards: the resolver fell back to whichever key was configured, /api/status/providers reported a healthy provider, and the setting the user actually chose silently had no effect — surfacing only much later, when the fallback stopped coinciding with intent. The validator checked key names, type, length, newlines and three per-key formats, but never whether a value belonged to its field’s domain. LLM_PROVIDERS — that exact 19-value list — has been exported from env-config.mjs all along; the validator simply never consulted it. It does now.
Notes
Only LLM_PROVIDER is enforced, and that is a deliberate narrowing of the report. The 16 model dropdowns are left unvalidated: their real domain is the vendor’s catalogue, which moves between our releases, so enforcing our curated list would stop a user selecting a model the provider already serves — this repo’s own suites configure gemini-2.0-flash, which the curated list dropped. The harm analysis points the same way: a wrong model fails loudly at call time, while only a wrong provider fails silently. select-remote is excluded for the same reason, and a drift test still proves the server and browser never disagree about what the dropdown offers.
An already-stored value is grandfathered, because a browser test proved the strict version unusable. public/js/views/config.js resends every non-secret field on every Save, touched or not, so refusing a pre-existing bad value froze the entire page: the user could no longer change anything until they fixed a field they had never touched. Only changes are refused now, which still blocks the reported defect — a new bad value cannot get in — while leaving an existing one repairable through the dropdown, which offers valid options only.
Also closed: OLLAMA_API_KEY accepted writes through the API but had no field in #/config, so anyone behind an authenticating Ollama proxy could not set it from the app at all. It has a descriptor now (secret, two new i18n keys ×17), and a test asserts the server’s 45 writable keys and the UI’s 45 descriptors stay equal. Neither defect came from v1.231.5 — that release’s diff was package.json, the lockfiles and docs. 3013 → 3018 tests, plus two browser cases in the newly-registered tests/playwright-card-overflow.mjs.
[1.231.5] — 2026-09-09
Security — seven advisories closed, one of them a critical remote-code-execution. No source file changed.
Security
Merged from Dependabot: multer 2.2.0 → 2.3.0 (high — denial of service via a file-descriptor leak on abort) and svgo 4.0.2 → 4.1.0 in the site (high + medium — removeScripts let executable links through namespace and content handling, and incompletely sanitized executable HTML inside foreignObject).
Then five more, surfaced by the rescan those merges triggered. Every affected range was already satisfied by the existing ^ specifier, so each needed an update rather than a manifest edit: astro 7.1.0 → 7.3.2 in the site — critical, remote code execution through AVIF image optimization (fixed in 7.2.8), and with it an authorization bypass from a missing path-segment boundary check when stripping the configured base (7.2.4); sharp 0.35.3 → 0.35.4 — high, two libheif vulnerabilities; and js-yaml 4.3.1 → 4.3.2 in both manifests — high, maxTotalMergeKeys did not limit CPU use for empty merge sources.
Notes
“Dependencies only” is not the same as “risk free” here, so this was not shipped on the audit result alone. js-yaml parses every config the app reads and multer accepts every upload it takes, and Astro moved a minor version. Verified: npm run test:ci 3013/3013, the browser suite 4/4, and a full site rebuild producing the same 86 pages, all 17 changelog mirrors and a correct facts.json. npm audit reports 0 vulnerabilities in both manifests. Test baseline is unchanged because no source file changed.
[1.231.4] — 2026-09-07
Fixed — v1.231.3 fixed the aftermath of the Doctor click, not the moment. Reported again, from mobile and from desktop.
Fixed
withSpinner still rewrote the button while the request was in flight. v1.231.3 made it snapshot the child nodes and restore them in finally, and the spans did come back — but the busy cue still ran button.replaceChildren(document.createTextNode('⏳ ' + original)), so for the whole length of a doctor.mjs run the 36 px square held the text ⏳ 🩺Doctor and became a wide pill that broke the row. On desktop it rendered as a jammed ⏳🩺Doctor. Restoring the children fixed the aftermath, not the moment, and an assertion that only looked at the end state passed it. A button that has element children is now never rewritten at all. Its cue is the .is-loading class, which the stylesheet already dimmed and which now swaps the icon for an hourglass in CSS, so the geometry cannot change: 36 px idle, 36 px in flight, 36 px after at 320 px. Text-only buttons — 23 of the 24 call sites — keep the prepended hourglass, unchanged since v1.58. On desktop the button reads ⏳Doctor while running, widening by the hourglass and returning, which is what every other button in the app has done for years.
Notes
The lesson is about the assertion, not the code. The v1.231.3 gate clicked Doctor, waited for aria-busy to clear, and then measured — so it could only ever see the state the finally block had already repaired. The new browser check holds the promise open itself rather than racing a real request, samples the button mid-flight, and asserts the width is unchanged, both spans still exist and nothing overflows. A first draft sampled after a real click and skipped the assertion when the request outran it; that would have been a lock that passes by luck. 3012 → 3013 tests. Both new checks were confirmed failing against the v1.231.3 code before this shipped.
[1.231.3] — 2026-09-07
Fixed — clicking Doctor broke the mobile top bar. Reported from a phone the day v1.231.2 shipped.
Fixed
UI.withSpinner flattened the button it was decorating. Its busy cue went through textContent: save button.textContent, write '⏳ ' + original, write it back. Assigning textContent replaces every child with one text node — and v1.231.2 had just split each top-bar action into .btn-ico + .btn-label spans, hiding the label under max-width: 900px. So the first Doctor tap destroyed both spans for good: the mobile rules had nothing left to match, the label came back as bare text, and the 36 px square grew into a 🩺Doctor pill that spilled over the theme toggle. It never healed — only a reload restored the markup. The fix snapshots the child nodes and restores them with replaceChildren; text-only buttons, which is most of the 24 call sites, round-trip exactly as before. A dataset.spinnerBusy flag stops a re-entrant call from snapshotting the hourglass as though it were the original content.
- FIND-3 — the search field had collapsed to 8 px on a 320 px phone. Putting the top bar on one row left the searchbar as the only flexible item, and this changelog said so a release ago: it “may shrink to nothing, so no negative space exists to distribute”. It did shrink to nothing — 8 px of input at 320 px, 28 px at 340 px, one character of a 21-character placeholder. Clickable and typable, but no longer legible as a field. No
min-widthcould fix it: at 320 px the bar holds 32 px of padding, a 40 px menu and 162 px of actions, with no 120 px left to give. So below 420 px — the width at which the field finally clears 100 px on its own — it now hides behind a magnifier and expands across the whole bar on tap: 182 px at 320 px, 222 px at 360 px, 252 px at 390 px, with the menu and the other actions stepping aside and no overflow at any width. The disclosure keeps one accessible name and reports state througharia-expanded, so nothing renames itself under the user, and the 🔍/✕ swap is pure CSS — no JS writes into that button, which is what broke Doctor in this same release. Above 420 px nothing changes: 109 px at 421 px, 447 px at 1280 px, exactly as before. Found by a browser regression pass against the live build.
Notes
This is the same defect class as the applyI18n() trap noted in v1.231.2 — code assigning textContent to an element that now has element children — reached through a different caller. v1.231.2 fixed the caller it knew about and shipped past this one. The lesson generalizes: splitting an element’s content into child spans makes every existing textContent writer on that element a latent bug, and the writers are not always in the file you are editing.
Locked twice. tests/with-spinner-preserves-children.test.mjs runs the real withSpinner against a fake button in node:vm and asserts the spans survive a full cycle plus the busy-state contract (returns the result, disables in flight, sets and clears aria-busy, restores the prior disabled). tests/playwright-narrow-viewport.mjs clicks Doctor in a real browser at 320 px and asserts the button width is unchanged, the label is still display:none, the actions share one row, and nothing scrolls. Both were confirmed to fail against the old code before the fix landed. 3009 → 3012 tests (the browser case is opt-in and not in test:ci).
[1.231.2] — 2026-09-06
Fixed — the mobile top bar wrapped into a second row, the server ran an older parent, and the cvstart.ru redirect 404ed any path that already named a language.
Fixed
- The mobile top bar is one row again, with icon-only actions. The action buttons were forced onto a full-width second row, so a phone showed
[☰ · search]above[🔔 🌙 Диагностика Открыть Scan]— two wide pills on a line of their own. Doctor and Open Scan are now 36 px squares (🩺 / ⚡) beside the bell and the theme toggle, and everything fits one row at 320 px. Measured, not eyeballed: document width equals viewport at 320, 360, 390 and 430 px, and across 560–760 px — the band where the buttons used to overlap the search pill. Locale no longer affects the mobile layout at all, because the label is hidden and the button is a fixed square; above 900 px the labels return unchanged. The accessible name comes fromaria-label, since adisplay:nonelabel is dropped from the name computation. - The server ran parent 1.31.0 while the release was built against 1.32.0. v1.231.0 was a parity release, but
/opt/career-ops/srcheld 1.31.0 files, so/api/healthon the production server reportedparentVersion: 1.31.0. 95 runtime files were synced and two removed (plugins-registry/theirstack.json, retired upstream;lib/context-budget.test.mjs, moved totests/). Reported by browser QA against the live surface. cvstart.ruprepended/ru/to every path, whatever was already there.cvstart.ru/ru/helpbecamecvstart.org/ru/ru/helpandcvstart.ru/en/helpbecamecvstart.org/ru/en/help— both 404. The first is what you get by copying a URL from the main site; the second locked English visitors out of the English site entirely, since any language prefix was overwritten. A path already naming one of the 16 locale prefixes now passes through,enis stripped (cvstart.org serves English at the root and has no/en/), and everything else still gets/ru/. The locale test is anchored, so/enterprise/is not mistaken foren.
Changed
- 98 superseded QA prompts moved to
qa/archive/superseded-prompts/.qa/README.mdhas documented since v1.137.0 that only the current per-release delta stays at the top level — the rule was then not applied for 95 releases. The README now says archiving happens when a release ships, and its own stale baselines are corrected (≥1238→≥3009tests, 13 → 17 locales, 19 H2 / 75 H3 → 32 H2 / 122 H3). - Five more prompts left the top level — they described themselves as current and were not.
REGRESSION-FINAL.mdcalled itself “the single authoritative, version-agnostic regression prompt”, but its §§11–15 are five closed cycle ledgers (v1.55.x → v1.59.7) and its stated help baseline was 28 H2 / 103 H3 across 16 locales against 32 / 122 and 17 today — a QA agent following it would have failed §9 on the first step.QA-REGRESSION-PROMPT.mdcarried an unversioned name and v1.226.0 content.QA-REGRESSION-PROMPT-v1.76.0-FULL.mdwas pinned 155 releases back,REGRESSION-PROMPT-FINAL.mdcloses the v1.58.52 → v1.59.10 cycle, andQA-FULL-REGRESSION.mdduplicated all of them at a v1.131.1 baseline.PARENT-SYNC-WORKLIST-v1.26.0.md(the parent is now 1.32.0) went with them, and the datedUX-AUDIT-2026-07-06.mdmoved toreports/. Four drivers remain: this release’s delta prompt plusFUNCTIONALITY-CHECK.md,UX-AUDIT-PROMPT.mdandDESIGNER-EXPORT-PROMPT.md, whose own baselines are refreshed to v1.231.2 actuals.qa/README.mdis rewritten to describe the folder that exists.
Notes
- Two specificity traps were walked into while fixing the top bar and are now commented in
app.css: the desktop.topbar/.topbar-actions/.btn-icorules are declared lower in the file than themax-width: 900pxblock, so at equal specificity they win — the mobilegapsilently stayed 24 px (which is what overflowed 320 px) and the icon stayed hidden (which rendered the buttons as empty squares). The mobile rules are one class deeper on purpose. applyI18n()assignsel.textContent, so the i18n key moved from the<button>onto an inner<span class="btn-label">; left on the button it would wipe the icon span on every language change.tests/qa-report-fixes.test.mjsBUG-008-tb now asserts the key appears inside#btn-doctorrather than as an attribute on it — the same invariant, checked where it now lives.- The parent deploy’s first rsync aborted mid-transfer on a file that no longer exists locally, leaving a partial deploy that reported success — the same failure shape as the dangling-symlink trap recorded in
QA-REGRESSION-PROMPT-v1.228.3.md. Parent deploys are now verified by checksum, never by exit code.
[1.231.1] — 2026-09-06
Fixed — v1.231.0’s count sweep rewrote the account name in every link.
Fixed
- Every
Fighter90link becameFighter92. The v1.231.0 source-count sweep replaced90with92under a digit-boundary guard —(?<![0-9])90(?![0-9])— which correctly skips290and901but matches the90insideFighter90, where the preceding character is a letter and the token simply ends. 562 links across all 17 READMEs, all 17 help bundles and the site’s help mirrors, plus 138 in the wiki, pointed at an account that does not exist; theproviders.pngbanner and every “edit on GitHub” link 404ed. Reported by a user who noticed the missing image. - Five historical release references shifted. The same release’s
85 → 87EN-adapter sweep turnedv1.85.0intov1.87.0in the README and help bundles, inventing a release that was never cut.
Notes
- A digit boundary is not enough. It guards a number against neighbouring digits, not against being part of an identifier or a version string — and both classes are dense in these files. The counts themselves are unchanged and correct: 92 sources, 87 EN adapters, 3009 tests.
- The parent repository was untouched: its counts were never swept.
[1.231.0] — 2026-09-05
Added — two scanner sources from parent career-ops v1.32.0 (Collage, Telegram strict), Gem’s REST mode, and one defect the port surfaced in the parent’s own code.
Added
- Two new sources, 90 → 92 (87 EN + 5 RU),
ALL_ADAPTERS85 → 87. Both zero-token.- Collage (
provider: collage) — Collage HR’s public job-site API. The path segment is an explicit job-site address the tenant chose, not a company-name slug, so it is read fromapi:or from asecure.collage.co/jobs/<address>careers URL and never guessed — a guessed address would scan somebody else’s board. An entry naming neither fails loudly. - Telegram (strict) (
provider: telegram-channel) — reads the samet.me/s/<channel>preview pages web-ui’s existingtelegramsource reads, with the opposite trade.telegramkeeps every post and attributes an unlabelled one to the channel handle;telegram-channelemits a row only when the post names an employer AND links to a vacancy page. Digests, hidden-employer posts (“название скрыто”, “our client”) and contact-only posts are dropped. Low recall by design — upstream measured 32-77% of posts passing on HR-curated channels — but every row carries a real employer and a real link. Both ship, as they do in the parent: they are different products, not duplicates.
- Collage (
- Gem gained an opt-in REST mode alongside its GraphQL path (
api: https://api.gem.com/job_board/v0/<board>/job_posts). Gem documents this unauthenticated surface for custom career pages. It is never derived from a board id — a derived URL would be a guess about which surface a tenant exposes — and the GraphQL path stays the default, so nothing changes for an entry that does not setapi:. Ported from parent #3783.
Fixed
telegram-channel’s location filter could not see Cyrillic, and named cities as employers. The parent’sLOCATIONISH_REused\b, which is ASCII-only and never fires next to Cyrillic — so every Russian alternative in it (москва,спб,офис,удалёнк*) was unreachable, andSenior Engineer | Москваreturned “Москва” as the employer. That is precisely the wrong-field-as-fact the whole attribution policy exists to prevent, and it landed on the provider’s primary audience: a Russian-language channel writes its location in Cyrillic. web-ui’s port uses Unicode lookarounds (\p{L}/\p{N}), matching what the parent already does one constant below inROLE_WORD_RE. Pinned by a test that fails against the ASCII form. Not yet fixed upstream.
Notes
- The parent’s Workday facet-recovery work (#3851, #3874) needed no port. It prioritizes configured locations when a split is chosen and tags a split whose facet under-covers the board. web-ui does not paginate or facet-split Workday at all — one POST at
offset: 0— so neither code path exists here. Same conclusion as v1.229.0, re-checked rather than assumed. providers/_types.jschanged only in documentation (the optionalJob.salaryfield).- Tests: 2962 → 3009 (+47).
[1.230.0] — 2026-09-05
Changed — three provider fixes from parent career-ops v1.32.0, and one defect the port surfaced here.
Added
- Welcome to the Jungle takes a server-side Algolia
filtersexpression (wttj: { filters: "offices.country_code:FR AND contract_type:full_time" }). A keyword alone cannot narrow a global board: Algolia’s relevance ranking, not the scanner’s own title/location filters, decides whichmax_hitsresults come back, so anything past the cap is invisible no matter how well it matches — the scanner’s filters run afterwards, on what already arrived. A filter shrinks the result set instead of re-ranking it, which is what makes a scan exhaustive rather than a sample, so with one the per-query cap rises from 200 to 1000. With a filter,queriesbecomes optional — the empty query means “everything that passes the filter”. Ported from parent #3757.
Fixed
- Radancy no longer warns “truncated” when a tenant simply has fewer postings than its own banner claims. The parent checked
data-total-resultsagainst nine live tenants: five matched, but four — a small one and a huge one both — served 10–56% fewer unique postings than advertised, with no duplicate rows anywhere. The banner overstates what the tenant’s search index actually serves, so comparing the accumulated count against it false-positived on a majority of boards. The warning now fires only when our ownmax_jobs/max_pagesstopped the walk, which is the only case the caller can act on;totalResultsstays in the message as context and never decides. Ported from parent #3839. - Radancy cache-busts every JSON fragment request. A caching layer in front of that route replays stale responses on some tenants — the same posting reappears pages later while another is never served at all, reproduced nine runs out of nine upstream.
Cache-Control/Pragmarequest headers changed nothing; a random per-request parameter fixed it. It must be unique per call, never derived from the page number: a deterministic parameter still gives the cache a stable key, and therefore the same stable, wrong mapping. Ported from parent #3839. - Greenhouse office lists are ordered deterministically. Greenhouse does not promise the order of a job’s
offices[]between responses, and the set recovered from the/officestree is in its traversal order. Both reach the posting’slocation, so a board that re-orders its offices rewrote that string — and a posting nothing had changed about read as new. The parent fixed the set (#3839); web-ui had a second instance of the same defect the parent does not have — theoffices[0]fallback used when a job carries no location of its own — and both are now sorted.
Notes
- The parent’s iCIMS fix (#3728) needed no port. It reads every
jobLocationentry from a detail page’s JSON-LD, because some tenants emit an all-UNAVAILABLEentry ahead of the real address. web-ui’s iCIMS takes the location from the list page’s HTML instead and never opens a detail page, so that code path does not exist here. - The parent’s
_http.mjschange needed no port. It extractsisRefusedRedirectErrorsodiscover-ats.mjsand the retry layer stop disagreeing about the same error object.isRetryableError’s behaviour is unchanged, and web-ui has no second consumer to share the verdict with — extracting a helper for one caller would be noise. - The parent’s location-aware dedupe (#3751) is not mirrored. It lives in the CLI’s
scan.mjsscan-history pipeline; web-ui’s scan route keeps its own. - Registry unchanged at 90 sources (85 EN + 5 RU). Tests: 2956 → 2962 (+6).
[1.229.0] — 2026-09-03
Added — four scanner sources from parent career-ops v1.31.0: Built In, Feishu Jobs, Garena and MokaHR.
Added
- Four new sources, 86 → 90 (85 EN + 5 RU),
ALL_ADAPTERS81 → 85. All four are zero-token: no API key, no login.- Built In (
provider: builtin) — the US tech board, board-wide across its nine markets. Employers post there directly, so it is an aggregator like RemoteOK, not a per-company source. It ships no default query: an entry with neitherqueries:norcategories:scans nothing and says so, because a shared source must never carry one user’s search terms. A market host that is not on the allowlist is refused, never quietly replaced by the national board — a typo must not silently widen the scan. - Feishu Jobs (
provider: feishu-jobs) — 飞书招聘, the endpoint every tenant’s own careers page calls. Two host shapes and nothing else:jobs.bytedance.comexactly, or a*.jobs.feishu.cntenant. They use different job-page paths, so one URL template would have produced dead links for half of them. - Garena (
provider: garena) — single-company, one fixed host.officeshapes the job LINK, never the listing: upstream verified live that every office code, invented ones included, returns the same board. - MokaHR (
provider: mokahr) — 国内 HR SaaS. The response is encrypted: an AES-128-CBC envelope whose key ships alongside the ciphertext. That is obfuscation rather than security, but it means a plain JSON parse sees nothing, so decryption is part of reading the board at all. Two tenants are refused at config time because their robots.txt excludes the careers path.
- Built In (
Fixed
htmlToTextcould leak an incomplete tag opener into plain text.safe <img src=x onerror=1came out assafe <img src=x onerror=1— the opener has no closing>for the tag strip to consume, so it survived. Each decode is now followed by a strip, and a trailing opener loses its angle bracket: the text stays readable and is inert. Ported from the parent (#3491).
Notes
- Nothing was ported from the parent’s Workday facet-split. It recovers tenants whose CXS backend clamps pagination at offset 2000 — web-ui does not paginate Workday at all (one POST at
offset: 0, 100 rows), so there is no clamp to work around. web-ui’s own 100-row ceiling is a separate, blunter limitation and not this release’s subject. - The parent’s
verify-cv-factsandmerge-trackerfixes needed no port: web-ui runs those scripts from the parent checkout rather than mirroring their logic, so they arrive with the parent. - Salary from Built In is formatted to a display string here. The parent’s
parseSalaryreturns{ min, max }for its own filters; web-ui’s job contract carriessalaryas text, and letting the object through would have put[object Object]in the tracker. - Tests: 2909 → 2956 (+47).
[1.228.5] — 2026-08-30
Fixed — the reported version described the file on disk, not the code being run.
Fixed
-
/api/healthre-readpackage.jsonon every request, so a stale process reported a version it was not running. QA found a local instance answeringversion: 1.228.4while returning 404 for/api/ping, a route added in 1.228.3 — started before the deploy, serving the old code, and reporting the new file’s version. That is exactly how a deploy that copies files but never restarts looks like a success: the one string an operator checks is the one that cannot see the problem. It is the same class as the v1.228.1 symlink, where rsync aborted mid-transfer and still reported success.The version is now captured when the module loads, so it describes the running code. A stale process reports the OLD version — the truth, and visible immediately.
parentVersionstays per-request: it describes the parent checkout, which this process does not load and which can legitimately change underneath a running server.
[1.228.4] — 2026-08-30
Fixed — the unauthenticated liveness probe did filesystem work on every request.
Fixed
GET /api/pingread and parsedpackage.jsonper request. It is the only endpoint reachable without credentials, so a read plus a JSON parse on every hit is a denial-of-service lever handed to anyone — CodeQL flagged it as missing rate limiting. The version is now read once when the route is registered; it cannot change without a restart anyway, so the handler does no I/O at all. Rate limiting would have capped the damage; removing the work removes the lever.- The profile owner’s name being masked off loopback is now pinned by a test.
/api/healthalready replaced it withhiddenon a non-loopback bind — but only because that row shares thehidden ?? valueguard with the project root. Nothing tested it, so an edit giving the row its own value would have leaked a real person’s name to the LAN with nothing to notice. Raised in review of v1.228.3.
[1.228.3] — 2026-08-30
Fixed — the docs assistant could not answer in English, Telegram company names arrived truncated, and a monitor had no way to see the service was up.
Fixed
-
The docs assistant answered “no matching help sections were found” in English while the same question worked in Russian. Retrieval was fine; the context builder was not.
buildAskPromptdidif (ctx.length + sec.body.length > MAX_CONTEXT) break— abreak, so one oversized section ended the loop and left the context empty, and the assistant truthfully reported it had nothing. The English §5 had grown to 16 081 bytes against a 14 336-byte budget (partly because v1.228.0 addedtelegram_channelsto it); the Russian §5 was 13 009 and fit. Same ranking in both languages — §5 was first — only the size differed.Two changes: a section past 6 KB is now split at its
###boundaries, sotelegram_channelsis its own retrievable chunk (1 340 bytes) that ranks first in both languages; and an oversized section is skipped or truncated rather than ending the loop. The guide went from 32 chunks to 75. -
A Telegram company label yielded the sentence after it, not the name.
Компания: FinCore Technology (продуктовая разработка, высоконагруженные системы)becameFinCore Technology (продуктовая разработка, высоконагруженны— capped at 60 characters, cut mid-word. Three of 141 rows. The name now ends at the first separator that introduces a description — a spaced dash (soCoca-Colasurvives), a comma, semicolon, pipe or opening parenthesis — and any remaining cap falls on a word boundary. This source attributes a company as fact; a truncated sentence is as wrong as a guessed name, only less obviously so.
Added
GET /api/ping— a liveness probe that leaks nothing.{ ok, version }and no more./api/healthreports absolute paths, the profile owner’s real name and which API keys are set: legitimate for an authenticated operator, never for the public internet. A monitor behind a Basic-auth gate previously had no way to see the service was up at all.
[1.228.2] — 2026-08-30
Fixed — a two-word search in /api/scan-results matched the phrase, not the words, and under-reported badly.
Fixed
-
?q=now matches every term independently instead of the literal phrase. The query was a singleincludes(q)over title + company + location, soпродакт менеджерfound only rows carrying those two words in that exact adjacency — 32 of the 162 that carry both.Менеджер продуктаandМенеджер по развитию продуктаwere invisible to it: the same words, reordered or with filler between them.This is how a narrow match becomes a wrong answer. The Telegram assistant asked for all Продуктовый менеджер reported 9 — faithfully, because 9 was what the endpoint said. The count it quoted was honest; the endpoint’s was not. Word order and the words between are not something a person typing two words intended to pin.
Matching is AND across terms, so widening precision costs nothing: a row must still carry every term.
менеджер разработчикreturns zero rather than the union.
[1.228.1] — 2026-08-29
Fixed — a dangling symlink in the repo that silently truncated deploys.
Fixed
-
.claude/skills/refero-designwas a tracked symlink to a path that has never existed here. It pointed at../../.agents/skills/refero-design, which resolves to.agents/at the repo root; there is no such directory, and the link arrived in the v1.118.0 parity pack from a machine where the surrounding folders nested differently. Anyone cloning the repo has been getting a broken link ever since.It was not inert. rsync aborts on it mid-transfer — a deploy today reported success while the server kept running the previous version, and the mismatch only surfaced because the version was checked afterwards. The parent project’s
node test-all.mjsalso crashes outright when a web-ui checkout sits inside its tree, because its fixture copy walks the working directory and cannot stat the link. Nothing referenced it: the onlyreferomatches in the repo are the Polish word preferowany.
[1.228.0] — 2026-08-29
Added — Telegram channels as a scan source, and a paged query mode on /api/scan-results.
Added
-
Telegram channels are now a scannable source. A
telegram_channels:block inportals.ymllists them; each is read from its public web preview athttps://t.me/s/<channel>. They live in their own block rather than intracked_companiesbecause they are not employers — the scanner filters that list throughdetectApi(), and a channel has no careers URL to detect. Registry: 85 → 86 sources (81 EN + 5 RU),ALL_ADAPTERS80 → 81.Telegram publishes no RSS and the Bot API cannot read a channel a bot does not administer, so neither obvious route works for someone else’s job channel. The
/s/preview is plain server-rendered HTML — no auth, no JS, no cookie. Verified across 16 channels: 15 returned 20 posts each on a bare GET.channel:accepts every way a person writes it —rabotaphp,@rabotaphp,https://t.me/rabotaphp, even a link to a specific post. The parser anchors on the three things a Telegram redesign is least likely to move:data-post="<channel>/<id>"(which both splits the page into post windows and carries the only stable URL a post has), the message-text container, and the ISO<time datetime>. -
GET /api/scan-resultsgained a paged query mode.?q=<text>&limit=50[®ion=ru|en][&set=filtered|fresh][&offset=N]returns{ total, returned, offset, limit, set, rows }, wheretotalis the count before paging. No parameters still returns the bare snapshot unchanged — the#/scantable depends on that shape.
Fixed
- The Telegram assistant was answering from a fraction of the data. Its skill already pointed at
/api/scan-results, but that route only ever returned the whole snapshot — ~2 MB on a real box — which does not fit in an agent’s context. Asked to “find all Продуктовый менеджер” it reported 9 matches where the snapshot held hundreds, and told the user it was “working with a limited local index”. That was true, and the cause was that it could see the endpoint but not consume it. With the paged mode the same question now costs 2 KB and returns an honesttotal. The skill was also rewritten: it had said to summarize the new postings, which points atfresh(last run only) rather thanfiltered(everything kept).
Notes
- A channel post is prose, not a job record. Unlike every other source here, there are no structured fields to read: the source lifts a title from the first substantive line, best-effort company/location/salary from explicit labels (
Компания:,Локация:, a currency amount), and leaves the full text in the description. Separating real postings from ads and digests is left to the existingtitle_filter— the same one already tuned for other sources. Treat these as leads, not listings. - A company is never guessed. With no
Компания:label the row is attributed to the channel (@rabotaphp), because a wrong employer enters the tracker as fact. - An empty parse is an error, not “no vacancies”. t.me answers a private or missing channel with a redirect; returning zero rows there would read as a quiet day and hide a typo in the config forever.
jobGeeksfrom the original list does exactly this (HTTP 302) and is deliberately not configured. - Remote detection uses a Unicode word boundary rather than
\b:\bis ASCII-only, so a space before a Cyrillic word is not a boundary and\bудалnever matched “удалёнка” — the same trap the title filter hit in v1.227.3, caught here by a test before it shipped. - 15 channels configured and verified live: 299 posts across 15/15 channels,
salary_pmyielding 13 salary figures out of 20. 30 new tests. Test suite: 2865 → 2895. - The README test counts jumped by hundreds, and no, this release did not add them. The localized READMEs carried unit-test totals frozen at 1856 / 1945 / 1955 / 549 / 419 / 284 / 2527 depending on the file — each stale since a different release, and several contradicting the badge at the top of the same page. They now all read 2895, verified by running the suite rather than by copying a badge. Playwright (70 / 90 / 12 → 101), e2e smoke (20 → 21), route modules (12 / 32 → 37), smoke flows (12 → 22) and the test-file count (218 → 329) were stale in the same way. The
Totalrow also repeated the unit count, so it under-summed the three suites below it; it now reads the real sum (3040).
career-ops-ui v1.267.1 · page 4/11 · mirror of the project changelog for cvstart.org