Nhật ký thay đổi
GitHubMọi thay đổi đáng chú ý, theo từng bản phát hành. Cùng một tệp đi kèm repository và được dịch sang cả 19 ngôn ngữ.
[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).
[1.227.5] — 2026-08-29
Fixed — the regional scan ran the server out of memory, and a Playwright suite flaked on its own teardown.
Fixed
- The regional scan OOM-killed the server.
ru-scanneraccumulated every raw hit from every query and only deduplicated and filtered at the very end. The query list is deliberately full of near-synonyms (Golang,Go разработчик,Golang разработчик,Senior Go…), so the same vacancy comes back once per query and the array held several times the unique count — with descriptions attached, which are the bulk of a job object and are almost all discarded by the filters anyway. Measured at 742 MB of heap on a real 21-query run. The server caps Node’s heap at 490 MB (V8 sizes it from the box’s 956 MB of RAM), so the scan crashed the service withFATAL ERROR: Reached heap limit— four times in one day. Dedup and filtering now happen per query, which took the same run to 177 MB, a 76% reduction. Reported counts are unchanged: the unique total is carried by aSetof URL strings, soTotal foundstill means what it always did. net::ERR_SOCKET_NOT_CONNECTEDfailed CI at random. Three Playwright suites callwindow.stop()to halt in-flight requests before asserting no console errors fired. Chromium reports that single deliberate act under three names depending on where the socket was:ERR_ABORTEDwhen it cancels the request,ERR_EMPTY_RESPONSEmid-flight, andERR_SOCKET_NOT_CONNECTEDwhen the socket was already gone. The shared benign-console filter listed only the first two, so the same teardown failed a run at random. It surfaced on the locale sweep intr— the suite with the most navigations, hence the most chances to lose the race — whileplaywright-formsandplaywright-smoke, which also callwindow.stop(), were one unlucky run away from the identical failure.
Notes
- Why the scan only started failing now. The query list grew from 14 to 21 when product-manager searches were added. The old implementation’s memory scaled with total hits, so +50% queries pushed it past the box’s ceiling. Nothing was wrong with the new queries — they exposed an allocation pattern that had been one config change away from failing for a long time.
- The dedup change is behaviour-preserving, and that is tested rather than asserted.
tests/ru-scanner-dedup-order.test.mjspins the awkward direction — a later duplicate that FAILS the filters must evict an earlier one that passed, because the old end-of-run pass kept only the last copy and then filtered it — and runs a 200-trial randomised equivalence check against the legacy accumulate-then-filter implementation. - The console filter stays narrow. Only the cancelled-request family is benign. A refused connection, DNS failure, connection reset, any 5xx and every uncaught JS exception still fail the assertion, pinned by new tests so the widening cannot creep.
- Two earlier hypotheses were tested and discarded rather than shipped: a Caddy timeout (its config is clean,
flush_interval -1is correct for SSE) and Node’s 5-minute defaultrequestTimeout(a scaled-down reproduction showed it does not terminate a long streaming response). The journal’sReached heap limitwas the actual evidence. Test suite: 2860 → 2865.
[1.227.4] — 2026-08-29
Fixed — word: / stem: were still ignored by content_filter; v1.227.3 only fixed the title filter.
Fixed
content_filterdid not honour the keyword prefixes. v1.227.3 taughttitle_filterthe parent’sword:/stem:prefixes but leftcontent_filteron its own rawlower.includes(k), so a prefixed entry there was still matched as the literal text"word:java"— the same silent no-op, one filter over.content_filterreads the job DESCRIPTION and its default has always been a plain substring, which is why a bare negativejavarejects every posting that merely mentions “JavaScript”; the prefix is how you opt one entry out of that. Ported the parent’scompileContentKeyword, which shares the prefix machinery but deliberately does not auto-anchor short keywords: the title filter anchors 2–3 letter acronyms because “COO” inside “Coordinator” is always wrong, whereas a short run inside a paragraph of prose is routinely intended (aws,gcp,sql,go).
Notes
- Caught in AI review of the v1.227.3 PR, which noticed the code did not cover what its own comment claimed — the comment said “
title_filter/content_filter” while only the title path was wired. The comment is now explicit about what each filter does: both honour the prefixes, only the title filter auto-anchors acronyms. - Also verified from that review, and unchanged: switching the 2–3 letter acronym rule from
\bto the Unicode boundary is behaviourally identical for ASCII and stricter only where it should be —COO_leadandcoo1are still kept (underscore and digit are word characters under both),Coordinatorkept,Директор COOandVP «Продукт»correctly dropped. 14 edge cases checked. - Unprefixed
content_filterentries keep their previous matching byte-for-byte, a blank/absent description still passes untouched, and a bareword:/stem:matches nothing rather than everything. 6 new cases intests/content-filter.test.mjs. Test suite: 2852 → 2858.
[1.227.3] — 2026-08-29
Fixed — word: / stem: title-filter prefixes were silently ignored, turning those filter lines into no-ops.
Fixed
- web-ui did not implement the parent’s
word:/stem:keyword prefixes.title_filter(andcontent_filter) default to case-insensitive substring matching, which is why a bare negativeinternalso rejects “International Product Manager” and “Internal Tools Engineer”. Rather than flip that default — a breaking change for every configured install — the parent made precision opt-in per entry:word:internmatches the whole word only,stem:agentmust start a word and may continue (separating “Agentforce” from “Reagents”). web-ui had neither, so it matched a prefixed entry as the literal text"word:intern"— which appears in no job title. The line became a silent no-op: the sameportals.ymlfiltered correctly through the CLI and not at all here, so every intern posting the user had excluded still reached the#/scantable. Ported from the parent’stitle-keywords.mjs, including the Unicode-aware boundary (\p{L}\p{M}\p{N}_) rather than\b, which is ASCII-only and matched mid-word in accented or Cyrillic titles. - The 2–3 letter acronym rule now uses that same Unicode boundary. It previously used
\b, socooandword:coodisagreed on non-ASCII titles — two spellings of one rule.
Notes
- Found while checking a live
portals.ymlthat had just adoptedword:internspecifically to stop “International Product Manager” being dropped by a bareintern. The prefix worked in the CLI; web-ui quietly kept every intern posting. Behaviour is now identical in both. - Defaults are unchanged. An unprefixed keyword keeps exactly its previous matching: substring for phrases and anything containing non-letters (
.NET,SAP,L&D), boundary-anchored for 2–3 letter acronyms. A bareword:/stem:with nothing after it is treated as a typo and matches nothing — as a negative, an empty pattern would match every title and veto an entire scan from one stray colon. - 6 new cases in
tests/title-filter.test.mjscovering both prefixes, the Unicode boundary, the bare-prefix typo, positive-side use, and that unprefixed defaults are untouched. Test suite: 2846 → 2852.
[1.227.2] — 2026-08-29
Fixed — #/config scrolled sideways on narrow phones.
Fixed
- MOBILE-1:
#/configoverflowed horizontally on viewports ≤ 352 px. Every select-kind field carried an inlinemin-width: 300px. An inline style beats the stylesheet, so the control could not shrink with its container: at a 320 px viewport the select’s right edge landed at 353 px, the page grew a horizontal scrollbar, and controls were pushed off the right edge. Affected all 18 provider/model selects (cfg-llm-providerand everycfg-*-model). Nowmin-width: min(300px, 100%)+max-width: 100%, so the 300 px comfort width applies only when there is room. Devices at or below the threshold: iPhone SE (1st gen, 320 px), older Android, Galaxy Fold cover screen.
Notes
- Why every earlier mobile pass missed it. 360 px and 375 px have just enough slack to hide the overflow, and the Playwright suites drive a 1280-wide window. Nothing in CI had ever measured a viewport narrower than 375 px. New
tests/playwright-narrow-viewport.mjssweeps the form-bearing routes at 320 px and fails on any horizontal overflow, naming the widest offending element in the assertion message; a second case pins that no#/configselect extends past the viewport. It was verified to reproduce the bug first (scrollWidth 353 > clientWidth 320, 18 selects at right edge 353). Playwright: 99 → 101. - Known, deliberately not gated yet: 280 px (Galaxy Fold cover screen) still overflows by ~5 px, from an unrelated cause — long content inside the config card’s
<details>blocks and the 256 px sidebar against a 280 px viewport, not the inlinemin-widththis release fixes. Reflowing at a width no mainstream phone reports is separate work; the suite’sWIDTHSlist is ready for it. - Also corrected the v1.227.1 QA prompt, which named the follow-up route as
#/modes/followup. Mode pages register bare (Router.register(cfg.slug)), so the route is#/followup;#/modes/followupcorrectly 404s. Documentation only. - Unit suite unchanged at 2846 (the new coverage is browser-level).
[1.227.1] — 2026-08-29
Fixed — the help guide’s source count contradicted itself, and its GitHub banner was printed as raw markup in the app.
Fixed
- §17 stated a total and a breakdown that did not add up. Every help bundle read “the
server/lib/sources/registry ships 85 adapters — 78 English + 5 Russian”. 78 + 5 = 83, not 85; ja/ko had drifted further, to 77 (= 82). Each new source bumped the total and left the breakdown alone, and the two numbers had been diverging for several releases. The breakdown is now 80 EN + 5 RU in all 17 locales. This mattered beyond cosmetics:docs/help/<lang>.mdis the ONLY grounding corpus for the “Ask the docs” assistant, so a user asking how many sources are supported got two contradictory numbers out of one sentence. - The same sentence pinned a stale version anchor. “As of v1.213.0 the registry ships …” survived in 16 locales while the count moved twice (83 in v1.219.0, 85 in v1.226.0). The anchor was already repaired once by hand in v1.210.1 and simply drifted again. It is now removed in favour of version-free wording — the count itself is asserted against the live registry, which cannot go stale.
- The provider-logo banner printed as escaped markup at the top of
#/help. v1.225.0/v1.225.1 recorded it as “stripped in-app byUI.md()(no image support)”. That conflated two things:UI.md()is escape-first — it HTML-escapes every source byte before any markdown transform, which is the correct XSS boundary but the opposite of removal. Nothing removed the banner, because no code ever tried, so all 17 locales opened#/helpwith 268 characters of literal<p align="center"><img src="https://…. A new shared ingress (server/lib/help-markdown.mjs::stripGithubOnlyBlocks) now drops standalone HTML image blocks on read, for bothGET /api/help/:langand the docs-assistant retrieval corpus. The bundles keep the banner on disk, so GitHub still renders the showcase. - The cadence board’s “#” column had been blank since v1.117.0. It read
entry.appNum, butfollowup-cadence.mjsemitsnum— a field name that never existed in the payload. Both are now read. The v1.227.0 “Next up” line inherited the same mistake and is fixed with it.
Notes
- These four went undetected because nothing gated them. The help gates (
canonical-docs-coverage,help-ui,help-ru-config-section) count HEADINGS — 32 H2 / 121 H3 — and no test had ever read a number inside a paragraph or looked at what the help ingress actually serves. Two new suites close that:tests/help-source-counts.test.mjs(the stated total equalsSOURCES.length; the breakdown parts equal the live EN/RU split and sum to the total; the paragraph pins no version) andtests/help-banner-strip.test.mjs(the banner stays on disk for GitHub, never survives the strip, every bundle’s first rendered line is its heading, the strip stays narrow, and both consumers route through it). Both were verified to fail against the exact bugs before being committed. Test suite: 2837 → 2846. - No registry, scanner or route-contract change; source count remains 85 (80 EN + 5 RU),
ALL_ADAPTERS80.
[1.227.0] — 2026-08-28
Added — SEEK Hong Kong (JobsDB), a due-only follow-up lens, and confidential employers now show who fronted them.
Added
- JobsDB Hong Kong via the existing Jobstreet/SEEK source. Hong Kong runs under the JobsDB brand but sits on the same SEEK platform behind the same v5 endpoint, so it needs no separate source — only
hk.jobsdb.comin the host allowlist andsiteKey: HK-Main. Registry is unchanged at 85 sources (80 EN + 5 RU) andALL_ADAPTERS80: this is a new MARKET on an existing adapter, not a new one. - Due-only lens on the follow-up cadence board (
#/modes/followup). A new checkbox narrows the board to what is actionable right now. Due-ness is read from theurgencythat the parent’sfollowup-cadence.mjsalready computed (urgent/overdue) — never from the trackerstatus, which isapplied/responded/interviewand would match nothing. When nothing is due, the board names the nearest scheduled follow-up instead of showing a bare empty state. Logic lives in the purepublic/js/lib/followup-view.js, mirroring the parent’sfollowup-view.mjs. - Confidential employers are attributed to the agency that fronted them. A posting whose employer is withheld is recorded as
?in the tracker with the recruiter in theViacolumn.#/trackershowed a bare?, so several confidential rows were indistinguishable and the logo resolver had nothing to match. Such a row now reads “Confidential · via” , resolves its logo from the agency name, and is findable by that name in the search box. Presentation only — the canonicalcompanyfield is never overwritten with the recruiter. New purepublic/js/lib/company-presentation.js, mirroring the parent’scompany-presentation.mjs.
Fixed
- Jobstreet/SEEK was calling a deprecated endpoint. The source still pointed at chalice-search v4 (
/api/chalice-search/v4/search), which SEEK retired; the parent migrated to/api/jobsearch/v5/searchand web-ui never followed. Every Jobstreet/SEEK entry now queries v5. The v5 item shape is read natively (job URL built fromid,locations[0].label,advertiser.description,salaryLabel), the v4-onlysolrFieldsprojection parameter is gone, and an entry whoseportals.ymlstill pins the dead v4 path keeps its market hostname while the path is rebuilt on v5 — no user edit required.
Notes
- career-ops 1.30.0 parity (parent HEAD
8d64f65→6cc46a4; the parent’sVERSIONstill reads 1.30.0 — release-please lags itsmain). Not ported, with reasons: iCIMS JSON-LD location fill — web-ui’s iCIMS source has noenrichDate()detail-page hook at all (list-pages only), so there is no code path to fix; nonfatal CLI stderr on clean exit (#1974) — web-ui’s runner decides failure from the exit code alone and never had the stderr-keyword heuristic that bug lived in; Turbopack path tracing — web-ui has no bundler;cv-sync-checkcrash/CODE_ROOTfixes — relayed read-only and fail-soft, so parent-side fixes land without a web-ui change; Playwright sequencing inscan.mjs, Block H application answers, the updater/doctor/update-systemguards, the recursive syntax gate, the Go dashboard, and thejd-skill-gap/verify-cv-factsself-test additions — CLI-only surfaces web-ui does not shell into, or self-tests with no behavior change. Test suite: 2818 → 2837.
[1.226.0] — 2026-08-27
Added — two Vietnamese job sources: ITviec and CareerViet.
Added
- ITviec + CareerViet (Vietnam). Two host-pinned Vietnamese boards join the scanner via
provider: itviec/provider: careervietentries: ITviec (itviec.com, tech-focused) and CareerViet (careerviet.vn, broad coverage). Both are zero-token HTML scrapers with SSRF host-pinning, HTTPS-only, per-company fail-soft, and page caps. Registry now ships 85 sources (80 EN + 5 RU),ALL_ADAPTERS80 — both appear in the#/scanSource filter.
Notes
- career-ops 1.30.0 parity (parentVersion 1.29.0 → 1.30.0). Not ported — CLI/community-only or already covered: the Hired Wall,
/calibrate, clean-markers, template packs, doctor, pdf/LaTeX, and the eval/tracker/scan CLIs web-ui does not shell into; the_html-to-textquoted-angle fix and detect-repostsroleFuzzyMatchwere already in web-ui; thetitle-filter stem:prefix is deferred (web-ui filtering lives inen-scanner). Test suite: 2818.
[1.225.1] — 2026-08-27
Fixed — the provider-logo banner now renders in the README and help.
Fixed
- The v1.225.0 banner did not display on GitHub: the standalone
images/providers.svghad an invalidfont-family(nested double-quotes) that GitHub rejects, the<img>used a repo-relativesrc(raw HTML<img>does not resolve relative paths on GitHub), and the<p>block had no trailing blank line so it merged with the next line. It now points at a renderedimages/providers.pngvia an absolute raw URL, with blank-line separation; the SVG is also corrected to valid XML.
Notes
- Docs-only; no code or test change. The cvstart.org showcase and the app’s own provider tiles were already correct (inline SVG, not this file). Test suite: 2784.
[1.225.0] — 2026-08-27
Added — a provider-logo banner in the README and help guide.
Added
- Provider-logo banner. A single self-contained
images/providers.svg— the 18 LLM providers with the real brand logo (simple-icons, CC0) for the 11 that publish one and a brand-colored monogram for the 7 that don’t — now heads the README (×17) and the in-app help guide. (The app renders the real logo tiles itself; the banner is GitHub-rendered and stripped in-app by the XSS-safe markdown renderer.)
Notes
- Docs-only — no code or test change; the SVG is inline (no remote asset). Scan sources unchanged at 83. Test suite: 2784.
[1.224.0] — 2026-08-26
Changed — the LLM provider tiles now show real brand logos.
Changed
- Real provider logos. The provider tiles — Settings API-key fields, the Usage rows, the dashboard chip, the Settings “Active” summary, the ⚡ eval result, and the onboarding banner — now render the real brand logo for the 11 providers that publish an open-source icon (Anthropic · Gemini · OpenAI · Qwen · OpenRouter · GitHub · DeepSeek · Kimi · MiniMax · Mistral · Ollama; single-path SVGs from simple-icons, CC0). The 7 without a published icon (Hermes · GLM/Z.ai · Grok · Together · Fireworks · BytePlus Ark · Volcengine Ark) keep the brand-colored monogram. Still CSP-safe by construction: the logo path is an inlined static constant — no remote asset, no
innerHTML— exactly like the monogram.
Notes
- No route or behaviour change; the same
ProviderLogo.el(slug)API.provider-logo.jsgrew (the inlined logo paths). Scan sources unchanged at 83. Test suite: 2784.
[1.223.0] — 2026-08-26
Fixed — a successful ⚡ live eval was painted as an error; plus install the OpenWorker coworker straight from its GitHub URL or .zip.
Fixed
- ⚡ eval result badge tone. A successful live evaluation is no longer coloured as a failure. The in-process live providers (OpenRouter, DeepSeek, …) return no subprocess exit
code, so the badge’s strictcode === 0test was false and every success rendered red (while still reading “exit 0”). The tone now follows the outcome — only a subprocess that exits non-zero paints it red; a code-less live result isbadge-ok, and the “· exit N” suffix shows only when there is a real exit code.
Changed
- Install the OpenWorker coworker from its GitHub URL / .zip. The coworker’s docs now describe the one-command installer (
curl … | bash, idempotent — it reuses an existing career-ops / web-ui) and the three OpenWorker install paths (GitHub URL ·.zip· single-file import) acrossdocs/integrations/openworker.md, in-app help §32 (×17), and the README.
Notes
- Closed a long-standing coverage gap: the Recruitee source now has an end-to-end test for the v1.214.2 quoted-angle fix (a
>inside a tag attribute must not leak into the description). Scan sources unchanged at 83. Test suite: 2783.
[1.222.0] — 2026-08-26
Changed — the extended-provider field hints (DeepSeek … Volcengine Ark) are now localized in all 17 languages.
Changed
- Localized provider field hints. Every
config.<slug>Hintfor the 11 OpenAI-compatible providers added in v1.216.0–v1.217.0 (DeepSeek · GLM/Z.ai · Kimi · MiniMax · Mistral · Grok · Together · Fireworks · Ollama · BytePlus Ark · Volcengine Ark) — the API-key, model, and base-URL hints under Settings → API keys — is now translated across all 17 locales instead of falling back to English. Signup URLs, model ids, and the ⚡ live-eval marker are preserved verbatim.
Notes
- No behaviour change — the English
hintFallbackinfield-specs.jsstill guards a missing key, and a new contract test asserts every field-descriptorhintKeyresolves in all 17 locales. Scan sources unchanged at 83. Test suite: 2779.
[1.221.0] — 2026-08-26
Security — DNS-rebinding defence-in-depth on the scanner fetch path.
Security
- The scanner HTTP core (
fetchJson/fetchTextinserver/lib/http-json.mjs) now resolves each source’s hostname on the real network path and refuses to connect if it resolves to a private, loopback, link-local, CGNAT, or cloud-metadata address (e.g.169.254.169.254) — closing a DNS-rebinding vector where a public hostname is served a private A record. Scanner hosts are already pinned to public registrable domains, so this is defence-in-depth; the user-supplied-URL path already had stronger connection-pinning (safe-fetch.mjs, since v1.20.1). The guard runs only on the realfetchtransport — an injected testfetchImplis never resolved, so the mocked source suites are unaffected.
Notes
- No behaviour change for a healthy scan (public boards resolve to public IPs). Scan sources unchanged at 83. Test suite: 2775.
[1.220.0] — 2026-08-26
Added — scan several Get on Board categories from one entry. The board splits leadership and ML/data roles out of programming, so an EM or data search now covers them without a second portal entry.
Added
- Get on Board: multiple categories per entry. A
getonbrdportal entry can now setcategories: [programming, operations-management, machine-learning-ai](or a singlecategory:) instead of only the defaultprogrammingfeed — postings are deduped by URL across categories, and the list is capped at 12. Existing entries are byte-identical (the default is stillprogramming).
Notes
- Server-only; the SSRF host-pin (www.getonbrd.com) +
redirect:'error'are unchanged, and a bad category slug is rejected before any fetch. Scan sources unchanged at 83. Test suite: 2771.
[1.219.0] — 2026-08-26
Added — Torre joins the scanner, and every provider label is now correct. A new LatAm-heavy remote board, a fix that stopped the a16z sweep short, and the last places that named providers from a stale short list now use the full 18-provider roster.
Added
- Torre (
provider: torre) — the pan-LatAm talent marketplace behind torre.ai, a public zero-auth opportunity search carrying remote roles that never reach Greenhouse/Lever/Ashby. Configuresearch(+experience) and optionalremote_only; one capped request per entry. The scanner registry now ships 83 sources (78 English + 5 Russian). - Provider monogram on the onboarding banner — the top-of-page “Live eval” chip now shows the active provider’s brand tile (joining Settings, Usage, the dashboard chip, and the ⚡ result).
Fixed
- The a16z Speedrun scan stopped early on a rotating board. When a listing was deleted between page requests, the feed returned a 49/50 page mid-sweep; that short page was treated as the last one, silently truncating the scan (149 of ~300 jobs). It now trusts the feed’s own
total_pagesand only falls back to a short page when the feed doesn’t report one — an empty page always ends iteration, so an over-reporting feed can’t run away. - Stale provider names. The global onboarding banner resolved the active provider from a 4-entry map (Anthropic/Gemini/OpenAI/Qwen) and showed a raw slug for the other 14; it now uses the shared 18-provider label + monogram. The Settings “which providers” note (“seven providers …”) now lists the full 18.
Notes
- Get on Board’s new multiple-categories-per-entry option isn’t wired here yet — the web-ui adapter builds the category into the endpoint URL, so it needs a source-level refactor; queued. Test suite: 2768.
[1.218.0] — 2026-08-26
Added — provider brand tiles (and correct names) everywhere a provider is shown. The dashboard “live evals” chip, the Settings active-provider summary, and the ⚡ evaluation result now each carry the provider’s monogram and name it correctly across all 18.
Added
- Provider monograms on the dashboard chip, the Settings “Active” summary, and the ⚡ evaluation result header — matching the tiles already in the Settings key fields and the Usage page.
Fixed
- Stale provider labels. Three surfaces resolved the name from a 5-entry map (Anthropic/Gemini/OpenAI/Qwen/OpenRouter) and fell back to the raw slug — the ⚡ evaluation result even mislabeled every non-Anthropic provider as “Gemini”. All three now use the shared
ProviderStatus.label(18 providers). - Settings key count showed “/ 7”; it now reflects the real provider total (18).
- The
LLM_PROVIDERhint’s slug list (… / ollama) gainedark/arkcnin all 17 locales, matching the auto-order arrow list.
Notes
- Client-only, CSP-safe (the same inline-SVG monograms). No server change. Scan sources unchanged at 82. Test suite: 2758.
[1.217.1] — 2026-08-26
Test hardening — endpoint coverage for the full provider roster.
Added
- A parametrized
run<Provider>test asserting the endpoint, default model, and Bearer auth for Kimi, MiniMax, Mistral, and Fireworks, so every OpenAI-compatible wrapper now has a direct endpoint check (not just structural coverage).
Notes
- No behavior change. Scan sources unchanged at 82. Test suite: 2756.
[1.217.0] — 2026-08-26
Added — Ark joins the LLM roster (18 providers). BytePlus Ark and Volcengine Ark — ByteDance’s Doubao models — are now ⚡ live-eval providers, wired through the same OpenAI-compatible core with a region base-URL override and their own brand tiles.
Added
- BytePlus Ark (
ARK_API_KEY, international) and Volcengine Ark (ARK_CN_API_KEY, China) — OpenAI-compatible Chat Completions via the sharedrunOpenAICompatible()core. SetARK_MODEL/ARK_CN_MODEL(a Doubao model name or yourep-…endpoint id);ARK_BASE_URL/ARK_CN_BASE_URLswitch region. Both join theautoorder (last) and each shows its monogram in Settings + Usage. The web UI now spans 18 live-eval providers.
Notes
- Same trusted-config + http(s) scheme-guard path as the rest of the roster (never the SSRF job-URL validator). Scan sources unchanged at 82. Test suite: 2755.
career-ops-ui v1.267.1 · page 3/10 · mirror of the project changelog for cvstart.org