Skip to content

feat(careers): job board + application widget as an opt-in stats.js module - #183

Merged
ralyodio merged 3 commits into
masterfrom
feature/careers-widget
Aug 3, 2026
Merged

ralyodio merged 3 commits into
masterfrom
feature/careers-widget

Conversation

@ralyodio

@ralyodio ralyodio commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Adds a careers module to the drop-in tracker, modelled on the pattern at digipowerx.com/careers — click a role, it expands inline, three fields and a link to apply.

How it ships

Customers already paste the stats snippet. Flipping Load careers widget (Stats → Careers, or the button on the Stats overview) makes that same snippet lazy-load /careers.js. No second script tag.

The cost discipline that makes this acceptable on every page of every customer site: stats.js only fetches the module when the page actually looks like a careers page — a [data-cp-careers] container, or a pathname match on /careers. Every other pageview pays one querySelector and a string compare. data-careers="off" and data-careers-path="/jobs" are the escape hatches.

Mount order: [data-cp-careers] → #careers → <main> → <body>, so "drop in one script and my /careers page auto-vivifies" works with no markup changes.

Crawlability is the point, not a bonus

A client-rendered job board is invisible to crawlers — the exact failure CrawlProof audits for. Shipping a naive embed would mean selling against our own product. So:

  • the widget writes a JobPosting JSON-LD graph into the host page
  • every open role also gets a server-rendered canonical page at /c/<project_id>/<slug>, with a board index at /c/<project_id>

Google for Jobs and answer engines get real HTML either way. This is the thing Greenhouse/Lever embeds structurally can't do.

Applicants

Three fields and a link — name, email, portfolio/LinkedIn/GitHub. No resume upload, so we never take custody of a file. Applications land in an inbox where a reviewer can shortlist / accept / reject (with undo), filtered by status and by role. Re-submitting the same email for the same role upserts instead of duplicating.

workplace is remote | hybrid | onsite rather than a boolean — hybrid is the case a boolean forces you to misreport, and each value maps to different schema.org output (remote → TELECOMMUTE with no jobLocation; hybrid → both; onsite → jobLocation only).

Surface

/careers.js the widget, dependency-free, never breaks the host page
/api/careers/jobs public feed, gated on both feature flags
/api/careers/apply public intake, service-role write
/c/[project], /c/[project]/[slug] server-rendered canonical pages
/projects/[id]/stats/careers manage roles + applicants

Nothing is served unless the project has both tracker_enabled and careers_enabled — enforced in the public_job_postings SECURITY DEFINER function, not just in the route.

Checks

  • tsc --noEmit clean
  • vitest run — 1206 passed, 1 file skipped (38 new tests; suite was 1195 before)
  • next build succeeds; all six new routes registered

/careers.js and /stats.js are hand-written JS in template literals that the compiler never parses, so tests/careers-widget-script.test.ts parses the served body with new Function and asserts the gating/escaping/JSON-LD markers. A syntax error there would otherwise ship straight to every customer's site.

Not covered

No DOM test environment is installed (environment: "node", no jsdom/happy-dom), so the widget's rendering and submit flow are not exercised — only parsed and asserted on content. Worth a follow-up if you want that, but it means adding a dev dependency.

The migration has not been applied to any environment.

…odule

Customers already paste the stats snippet. Flipping "Load careers widget"
in the dashboard makes that same snippet lazy-load /careers.js, which
paints a job board and an inline application form on their /careers page.
No second script tag.

The cost discipline that makes this acceptable on every page of every
customer site: stats.js only fetches the module when the page actually
looks like a careers page — a [data-cp-careers] container, or a pathname
match. Everything else pays one querySelector. data-careers="off" and
data-careers-path="/jobs" are the escape hatches.

A client-rendered job board is invisible to crawlers, which is the exact
failure CrawlProof audits for. So the widget also writes JobPosting
JSON-LD into the host page, and every open role gets a server-rendered
canonical page at /c/<project_id>/<slug>. Google for Jobs and answer
engines get real HTML either way.

Applications are three fields and a link — no resume upload, so we never
take custody of a file. They land in an applicant inbox where a reviewer
can shortlist, accept, or reject, filtered by status and role.

Workplace is remote/hybrid/onsite rather than a remote boolean: hybrid is
the case a boolean forces you to misreport, and each value maps to
different schema.org output.

Tests cover the pure helpers plus the two hand-written browser scripts,
which the compiler never parses — a typo there would ship straight to
every customer's site.
@ralyodio
ralyodio marked this pull request as ready for review August 3, 2026 14:44
serveAd diverts HOUSE_AD_ROTATION_RATE (10%) of otherwise-fillable
requests to the house ad, which is unmetered and carries no clickUrl.
Four of the tests in this file assume a paid fill, so each failed ~10% of
the time — about a 1-in-4 chance of a red run on a branch that changed
nothing near ads. Measured on an untouched tree: 4 failures in 15 runs;
0 in 15 with the rotation pinned off.

The failure surfaced as ".toMatch() expects to receive a string, but got
undefined" on whichever of the four lost the coin flip, which is why it
moved around between runs.

Rotation itself is covered by tests/contract/ads-house-rotation.test.ts,
so nothing is lost by making this file deterministic.
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

ThreatCrush Security Scan

52 finding(s)

HIGH/CRITICAL: 10 | MEDIUM: 42

Severity Rule Location
HIGH secret-generic-credential app/(marketing)/docs/autoblog-webhook/page.tsx:145
HIGH secret-generic-credential lib/sp/platforms/facebook.ts:32
HIGH js-ssrf-outbound-request lib/sp/platforms/facebook.ts:115
HIGH secret-generic-credential lib/sp/platforms/linkedin.ts:25
HIGH js-ssrf-outbound-request lib/sp/platforms/telegram.ts:63
HIGH js-ssrf-outbound-request lib/sp/platforms/threads.ts:138
HIGH manifest-typosquat package.json:59
HIGH secret-generic-credential tests/contract/coinpay.test.ts:4
HIGH secret-generic-credential tests/contract/posthog-integration.test.ts:13
HIGH secret-generic-credential tests/lead-campaign.test.ts:16
MEDIUM js-unescaped-html-sink app/(app)/admin/email-broadcast/EmailBroadcastForm.tsx:125
MEDIUM sql-template-interpolation app/(app)/projects/[id]/autoblog/actions.tsx:96
MEDIUM js-unescaped-html-sink app/(app)/projects/[id]/autoblog/articles/[articleId]/page.tsx:214
MEDIUM sql-template-interpolation app/(app)/projects/[id]/autoblog/setup/form.tsx:504
MEDIUM sql-template-interpolation app/(app)/projects/[id]/uptime/monitor-actions.tsx:28
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:67
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:97
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:104
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:110
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:186
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:190
MEDIUM sql-template-interpolation app/actions/admin.ts:114
MEDIUM sql-template-interpolation app/actions/orgs.ts:328
MEDIUM sql-template-interpolation app/api/lx/keywords/regenerate/route.ts:59
MEDIUM js-unescaped-html-sink app/c/[project]/[slug]/page.tsx:77
MEDIUM js-unescaped-html-sink app/c/[project]/page.tsx:57
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:196
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:223
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:279
MEDIUM js-unescaped-html-sink app/layout.tsx:129
MEDIUM js-open-redirect app/login/form.tsx:39
MEDIUM js-unescaped-html-sink app/r/[token]/page.tsx:176
MEDIUM js-open-redirect app/signup/form.tsx:43
MEDIUM js-open-redirect components/billing/buy-credits-modal.tsx:98
MEDIUM js-unescaped-html-sink components/json-ld.tsx:8
MEDIUM js-unescaped-html-sink components/report/markdown-view.tsx:15
MEDIUM sql-template-interpolation lib/audit/checks/security.ts:48
MEDIUM redos-nested-quantifier lib/careers/jobs.ts:139
MEDIUM redos-nested-quantifier lib/emailMarkdown.ts:130
MEDIUM redos-nested-quantifier lib/lx/articleGen.ts:93
MEDIUM sql-template-interpolation lib/lx/articleGen.ts:367
MEDIUM sql-template-interpolation lib/lx/articleGen.ts:379
MEDIUM sql-template-interpolation lib/lx/articleGen.ts:380
MEDIUM sql-template-interpolation lib/lx/articleGen.ts:1340
MEDIUM sql-template-interpolation lib/lx/articleGen.ts:1362
MEDIUM sql-template-interpolation lib/lx/guestPostGen.ts:109
MEDIUM tls-verification-disabled lib/onion.ts:47
MEDIUM sql-template-interpolation lib/sp/platforms/linkedin.ts:177
MEDIUM sql-template-interpolation scripts/delete-archived-projects.mjs:97
MEDIUM sql-template-interpolation scripts/delete-archived-projects.mjs:102

…and 2 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio

ralyodio commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Why there's an unrelated ads commit here

CI went red on tests/contract/ads-short-code-serving.test.ts, which this branch doesn't touch. It's a pre-existing flake on master, not something the careers work introduced.

serveAd diverts HOUSE_AD_ROTATION_RATE (10%) of otherwise-fillable requests to the house ad, which is unmetered and so carries no clickUrl. Four tests in that file assume a paid fill, so each fails ~10% of the time — roughly a 1-in-4 chance of a red run on any branch. That's why the failure moved: CI lost the coin flip on line 112, my local run lost it on line 142.

Measured on an untouched tree:

failures
before 4 / 15 runs
after 0 / 15 runs

Fix pins rotation off for that file only (fd85280). Rotation itself stays covered by tests/contract/ads-house-rotation.test.ts, so no coverage is lost.

Happy to split that commit into its own PR if you'd rather keep this one pure — it just had to be fixed for this branch to go green.

Also merged latest master in (no conflicts). Full suite: 1295 passed, ci / security / threatcrush all green.

@ralyodio
ralyodio merged commit c8a9d4a into master Aug 3, 2026
8 checks passed
@ralyodio
ralyodio deleted the feature/careers-widget branch August 3, 2026 15:00
ralyodio added a commit that referenced this pull request Aug 3, 2026
serveAd diverts HOUSE_AD_ROTATION_RATE (~10%) of otherwise-fillable
requests to the house ad, which is unmetered: no impression row, so no
short code and no /a/ click URL. Four of the five tests in this file read
fields off whatever came back, so each failed on ~10% of runs — about one
CI run in three went red, on whichever test happened to draw the house ad.

Measured on master before this change: 6 of 15 runs failed, across four
different tests. That is what took down the builds on #179 and #183,
neither of which touches ads.

These tests are about the click URL of a *paid* fill, so the draw is
pinned above the threshold. Fixing Math.random is safe precisely here:
the auction has a single candidate either way, and short codes come from
crypto.randomBytes, so "issues a distinct code per paid fill" still
exercises real entropy — and now checks all 60 fills instead of a subset
that silently shrank whenever the house ad turned up.

Pinning the draw would have deleted the only coverage of the rotation, so
it is asserted directly rather than left to chance: one test for a draw
under the rate, one for a draw exactly on it. The boundary is the
interesting part, and sampling the rate would just be a slower way to
reintroduce a flaky test.

40 consecutive runs of the file now pass, against 9 of 15 before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ralyodio added a commit that referenced this pull request Aug 3, 2026
#183 de-flaked tests/contract/ads-short-code-serving.test.ts by mocking
HOUSE_AD_ROTATION_RATE to 0 for that file, which was the right call — the
tests there are about the click URL of a paid fill, and the coin flip made
roughly one CI run in three go red on whichever test lost it.

That commit notes rotation is still covered by ads-house-rotation.test.ts.
It isn't, quite: that file calls houseFill() directly and never goes
through serveAd, so it pins what a house ad looks like, not when one is
served. With the rate pinned to 0 in the only file that reached it, the
branch at lib/ads/serve.ts:213 has no test left.

That branch is what keeps the network advertising itself on slots that are
already selling, and it is one comparison — the kind of thing that can be
deleted or inverted in a refactor and show up as a revenue question months
later, not a red build.

So it is pinned here against serveAd, by driving the draw to each side of
the threshold rather than sampling a ~10% frequency. Sampling would mean
reintroducing exactly the coin flip that made the other file flaky; a
controlled draw tests the same behaviour and cannot fail intermittently.

Verified by mutation: '<' to '<=' fails 1 test, inverting the comparison
fails 3, deleting the branch fails 2.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant