JobHub

A private, account-based job-search tool. It finds jobs, matches roles, prepares résumés from evidence and plans your calendar. I defined the product and its data rules, directed an AI coding assistant to build it, and reviewed the output.

Runs on
Cloudflare Workers
Data
Two databases with account-scoped data, 28 tables in the accounts database
Layers
Budgeted job collectors and a bounded language-model layer
Explore details

Cloudflare Workers · D1 · invite-only sign-in

JobHub

A private job-search tool with account-scoped data, a two-database design, budgeted job collectors and a bounded language-model layer.

Product direction and code review are mine. An AI coding assistant wrote much of the code.

The system map

Screens are scripted previews with sample data.

JobHub does not submit applications for you.

Scrolling moves the camera. Select a box to inspect it. Up and Down step between boxes.Tap a box to inspect it.

Source notes

Reviewed source snapshot: feat/landing-feature-demos @ c65956d. Where a fact lists what was not audited, the line says so.

D1 tables, accounts database
grep -ic '^create table' schema.sqlschema.sql
D1 tables, job-data database
Unique CREATE TABLE / CREATE VIRTUAL TABLE names across migrations-job-data/*.sqlmigrations-job-data/
Migrations, accounts database
ls migrations | wc -l (0001 to 0008)migrations/
Migrations, job-data database
ls migrations-job-data | wc -l (0001 to 0009)migrations-job-data/
API handler files
find functions/api -name '*.js' | wc -l. Several are [action].js dispatchers, so the URL count is higher than this.functions/api/
Exported request handlers under functions/
grep for `export function/const onRequest*`: 3 onRequest, 10 onRequestGet, 9 onRequestPost, 3 onRequestDelete, 1 onRequestPut. Counts handlers, not URLs.functions/
Library modules
ls lib/*.js | wc -llib/
API providers configured in the collector
Top-level keys of PROVIDERS: greenhouse, lever, ashby, smartrecruiters, recruitee, workable, adzuna, usajobs. Collector rule: official public APIs only.lib/job-providers.js:70
Collector rules written in code
String entries in COLLECTOR_RULES; the first is 'Use only official public job APIs; never use AI to discover or choose jobs.'lib/collector.js:44
Collector budget per run
Constants FREE_REQUESTS, FREE_DB_QUERIES, FREE_WORK, FREE_PAGES.lib/collector.js:9
Scheduled Worker triggers
crons array: '0 */8 * * *' and '40 10 * * *'worker/wrangler.toml:10
How the AI is bounded
Documented rules: docs/JEV-RULES.md and REPLIES in lib/note-sort.js. Call sites were not fully audited.docs/JEV-RULES.md
Supplied text is marked as untrusted and wrapped in delimiters
UNTRUSTED_RULE, fence() and stripMarkers() are defined in jev-api.js; grep finds UNTRUSTED_RULE on 9 lines across lib/.lib/jev-api.js:48
Postings and résumés are checked with looksLikeInstructions() before being sent to Jev
looksLikeInstructions() is called in finder.js (lines 260, 292) and resume-review.js (152).lib/jev-api.js:70
Protected API requests without a session get
The middleware has four public POST exceptions (PUBLIC_POSTS); other /api/ requests without a session get 401 'Sign in first.'functions/_middleware.js:13,101
Password hashing
ITERATIONS constant and deriveBits calllib/auth.js:6,31
Occurrences of `user_id = ?` in functions/ and lib/
grep -rn 'user_id = ?' functions lib | wc -l. AGENTS.md requires every D1 query to be scoped. This counts the pattern and does not prove full coverage.AGENTS.md
Application submission
Sources: public/index.html:44 and lib/note-sort.js:11,16,25. These contain the policy and fixed reply; enforcement was not fully audited.lib/note-sort.js:11,16,25
Two D1 databases with different owners
wrangler.toml binds DB and JOBDATA. grep -c user_id migrations-job-data/*.sql returns 0 for all 9 files. Accounts schema.sql has user_id on the per-person tables. Only lib/collector.js and the owner-only operations routes write JOBDATA; finder.js and catalogue.js read it.wrangler.toml; migrations-job-data/; schema.sql
Direction of data flow between the databases
lib/finder.js: JOBDATA.prepare(SELECT ... FROM postings WHERE open=1 ...) for candidates; db.prepare(...) on jobs, seen and meta keyed by user_id for known rows and results.lib/finder.js:190,228
Budget clamp per run
effectiveConfig() takes the minimum of the constants and the global, provider and host rows of collector_budget_config; table CHECK constraints in migration 0004 bound each column. The code comment ties the 25 to the 50-subrequest Free limit. 4 statements are reserved for the keyword reindex batch.lib/collector.js:9,124-137,367-375; migrations-job-data/0004_collector_state.sql:3-14
How the budgets are enforced
budgetedFetch() throws code BUDGET at the limit and boundedBody() aborts the reader past the byte limit. statementBudget().batch() returns false when sqlArgs.length > left.lib/collector.js:64-122
Page checkpoint and postings in one batch
The same D1 batch writes the stored postings, the observations and the source_schedule checkpoint, so a cursor never advances past rows that were not stored.lib/collector.js:330-364
A failing source backs off alone
runCollector wraps each sourcePage() in try/catch; on error it writes failure_count, backoff_until and last_result for that source only and continues the loop. Backoff is 30 seconds doubled per failure, capped at 6 hours, or the provider Retry-After.lib/collector.js:62,420-427
Complete cycles of absence before a posting closes
posting_observations.missing_complete_cycles increments only when a board cycle ends terminal, complete and non-empty; closure needs >= 2. A reappearance resets it to 0.lib/collector.js:340-358
No description-less stub is stored
For providers that fetch one request per description (Greenhouse, SmartRecruiters) the loop cuts the page before the first job it has no request budget for.lib/collector.js:205-212
Closed postings are deleted after 30 days
PRUNE_DAYS = 30; DELETE ... WHERE open=0 AND embedded=0 and last observation older than the cutoff, LIMIT 100 per run. Child rows cascade; the FTS table has a delete trigger.lib/collector.js:11,466-472
Posting identity
lib/collector.js:44-52; migrations-job-data/0004_collector_state.sql:54-70
Candidate shortlist
KEYWORD_ROWS = 1000 behind an FTS5 MATCH (tokenizer keeps underscores); up to 100 Vectorize neighbours; finder-pool.js TTL = 8 hours; collector VECTOR_CAP = 12000. Documented in docs/MATCHING-METHODOLOGY.md; the 100 semantic rows are from that document and were not re-derived from code.lib/finder.js:143,190; lib/finder-pool.js:1; lib/collector.js:10; migrations-job-data/0009_posting_keyword_search.sql
Requirements and evidence per assessment
postingRequirements(text, max=8) and prepareMatchEvidence(hits.slice(0,4)) in lib/match-evidence.js; MAX_JEV = 10 in lib/finder.js:18; per-click 50 is stated in SEARCH_RULES rule 10 (lib/finder.js:120).lib/match-evidence.js:29,58; lib/finder.js:18,120
Fit score is computed in code
matchDetail() in lib/match-evidence.js. Coverage is recomputed from the per-requirement classifications and overrides the model's global answer. A positive classification without a cited source is downgraded to unknown; capability_gap requires negation wording in the cited excerpt. GPA is compared numerically in code.lib/match-evidence.js:68-105
Question types sent to the model
Thresholds read from the docs table, not re-derived from every call site.docs/JEV-RULES.md
Phrase patterns in the injection check
INSTRUCTION_PATTERNS array length. The file comment and JEV-RULES.md say it is a fixed phrase list that a reworded attack can get past; the fence and the closed answer types still apply.lib/jev-api.js:60-70
What the assessment cache stores
docs/JEV-RULES.md Known limits; docs/MATCHING-METHODOLOGY.md. Not re-derived from the cache code.docs/JEV-RULES.md
Tenant isolation tests and responses
_middleware.js returns 403 on an Origin mismatch and 401 without a session.functions/_middleware.js:62,101
Per-account keys
schema.sql lines 13, 16, 27, 35 and 44.schema.sql
Daily D1 allowance gate
READ_LIMIT, WRITE_LIMIT, READ_RESERVATION and WRITE_RESERVATION in lib/d1-budget.js. A Durable Object holds the counter; the raw D1 bindings live only in the gate Worker. Each call reserves first, persists the reservation, runs the SQL, then replaces the reservation with the measured rows. Missing or unmeasured results lock the day. docs/D1-BUDGET-GUARD.md says this is an application circuit breaker, not an account-wide cap.lib/d1-budget.js:1-3; lib/d1-client.js; guard/index.js; docs/D1-BUDGET-GUARD.md
What each timer does
worker/index.js:10-19 and worker/wrangler.toml crons. docs/JOB-DATA-BOTS.md still says a 5-minute heartbeat and README.md says a 4-hour timer; both disagree with wrangler.toml, which is used here.worker/index.js; worker/wrangler.toml
Migrations
migrations-job-data/0004 header comment; KEYWORD_VERSION = 2 and the bot_rules cursor in lib/collector.js:432-450; 0009 header comment. Migration files 0007 and 0008 are data seeds without IF NOT EXISTS.migrations-job-data/; lib/collector.js