{"record":{"id":"cb53824fba6eb377","repo":"santifer/career-ops","slug":"recruitee-cannot-derive-api-url-for-entry-name","errorCode":null,"errorMessage":"recruitee: cannot derive API URL for ${entry.name}","messagePattern":"recruitee: cannot derive API URL for (.+?)","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"providers/recruitee.mjs","lineNumber":51,"sourceCode":"    return null;\n  }\n  if (parsed.protocol !== 'https:') return null;\n  if (!RECRUITEE_HOST_RE.test(parsed.hostname)) return null;\n  return `https://${parsed.hostname}/api/offers/`;\n}\n\n/** @type {Provider} */\nexport default {\n  id: 'recruitee',\n\n  detect(entry) {\n    const apiUrl = resolveApiUrl(entry);\n    return apiUrl ? { url: apiUrl } : null;\n  },\n\n  async fetch(entry, ctx) {\n    const apiUrl = resolveApiUrl(entry);\n    if (!apiUrl) throw new Error(`recruitee: cannot derive API URL for ${entry.name}`);\n    assertRecruiteeUrl(apiUrl);\n    const json = await ctx.fetchJson(apiUrl, { redirect: 'error' });\n    return parseRecruiteeResponse(json, entry.name);\n  },\n};\n\n/**\n * Parse a Recruitee /api/offers/ response. Exported for unit tests.\n *\n * Recruitee returns:\n *   { offers: [{ title, careers_url?, url?, city?, country?, remote?, location? }] }\n *\n * - url: prefer `careers_url`, fall back to `url`. Recruitee tenants commonly\n *   serve postings on their own custom domain (e.g. `careers.hostaway.com`),\n *   so this URL is NOT host-locked to `*.recruitee.com`. Unlike the API\n *   endpoint, the per-offer URL is display-only — it is written to the pipeline\n *   and scan history but never server-fetched here, so the SSRF rationale does\n *   not apply. It is sourced from the already-validated tenant API response.","sourceCodeStart":33,"sourceCodeEnd":69,"githubUrl":"https://github.com/santifer/career-ops/blob/9b17a8ac97b398a496b38e423ae24e433b43254f/providers/recruitee.mjs#L33-L69","documentation":"fetch() throws when resolveApiUrl(entry) returns null. resolveApiUrl returns null if entry.careers_url is missing/non-string, unparseable, not HTTPS, or its hostname fails RECRUITEE_HOST_RE. The derived API endpoint would be https://<slug>.recruitee.com/api/offers/. This fires at fetch time when the entry cannot produce that endpoint.","triggerScenarios":"entry.careers_url is undefined or empty; it is http:// (not https); its hostname is not *.recruitee.com; it is a malformed string that throws inside new URL().","commonSituations":"A job_boards row lacks careers_url; detect() returned null but fetch was called anyway; a config rename dropped the URL field; the entry references a branded domain incompatible with recruitee auto-detection.","solutions":["Provide a valid https://<slug>.recruitee.com careers_url on the entry.","Call detect(entry) before fetch() and skip when it returns null.","Verify the config field name is careers_url (not career_url or url).","If the board uses a branded domain, switch to the correct provider or supply the recruitee.com subdomain explicitly via api:."],"exampleFix":"// before\nawait provider.fetch({ name: 'Acme' }, ctx);\n// after\nif (provider.detect(entry)) await provider.fetch(entry, ctx);","handlingStrategy":"validation","validationCode":"if (!provider.detect(entry)) {\n  console.warn(`skip ${entry.name}: recruitee cannot derive API URL`);\n  continue;\n}\nawait provider.fetch(entry, ctx);","typeGuard":"null","tryCatchPattern":"try {\n  await provider.fetch(entry, ctx);\n} catch (e) {\n  if (/cannot derive API URL/.test(e.message)) {\n    console.warn(`[skip] ${entry.name}: missing or non-recruitee careers_url`);\n  } else throw e;\n}","preventionTips":["Gate fetch() on detect() returning non-null.","Ensure every recruitee entry has a string https://<slug>.recruitee.com careers_url.","Validate config field names (careers_url, not url)."],"tags":["config","provider","recruitee","missing-url","resolution"],"backgroundTag":null,"analyzedSha":"9b17a8ac97b398a496b38e423ae24e433b43254f","analyzedAt":"2026-08-13T00:48:39.135Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}