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
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.sql
schema.sql - D1 tables, job-data database
- Unique CREATE TABLE / CREATE VIRTUAL TABLE names across migrations-job-data/*.sql
migrations-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 -l
lib/ - 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 call
lib/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
