// @ts-check /** @typedef {import('./_types.js').Provider} Provider */ // Greenhouse provider — hits the public boards-api JSON endpoint. // Handles both explicit `api:` URLs and auto-detection from `careers_url`. const ALLOWED_GREENHOUSE_HOSTS = new Set([ 'boards-api.greenhouse.io', 'boards.greenhouse.io', 'job-boards.greenhouse.io', 'job-boards.eu.greenhouse.io', ]); /** @param {string} url */ function assertGreenhouseUrl(url) { let parsed; try { parsed = new URL(url); } catch { throw new Error(`greenhouse: invalid URL: ${url}`); } if (parsed.protocol !== 'https:') throw new Error(`greenhouse: URL must use HTTPS: ${url}`); if (!ALLOWED_GREENHOUSE_HOSTS.has(parsed.hostname)) throw new Error(`greenhouse: untrusted hostname "${parsed.hostname}" — must be one of: ${[...ALLOWED_GREENHOUSE_HOSTS].join(', ')}`); return url; } /** @param {import('./_types.js').PortalEntry} entry */ function resolveApiUrl(entry) { if (entry.api) { assertGreenhouseUrl(entry.api); return entry.api; } const url = entry.careers_url || ''; const match = url.match(/job-boards(?:\.eu)?\.greenhouse\.io\/([^/?#]+)/); if (match) return `https://boards-api.greenhouse.io/v1/boards/${match[1]}/jobs`; return null; } // NaN-safe Date.parse — `|| undefined` would also coerce a valid epoch 0. function toEpochMs(value) { if (!value) return undefined; const parsed = Date.parse(value); return Number.isNaN(parsed) ? undefined : parsed; } /** @type {Provider} */ export default { id: 'greenhouse', detect(entry) { try { const apiUrl = resolveApiUrl(entry); return apiUrl ? { url: apiUrl } : null; } catch { return null; } }, async fetch(entry, ctx) { const apiUrl = resolveApiUrl(entry); if (!apiUrl) throw new Error(`greenhouse: cannot derive API URL for ${entry.name}`); assertGreenhouseUrl(apiUrl); // redirect:'error' prevents SSRF via server-side redirects; combined with // assertGreenhouseUrl above it guarantees the final hostname stays in the allowlist. const json = /** @type {any} */ (await ctx.fetchJson(apiUrl, { redirect: 'error' })); const jobs = Array.isArray(json?.jobs) ? json.jobs : []; return jobs.filter(/** @param {any} j */ j => j.absolute_url).map(/** @param {any} j */ j => ({ title: j.title || '', url: j.absolute_url, company: entry.name, location: j.location?.name || '', postedAt: toEpochMs(j.first_published), })); }, };