{"record":{"id":"ed0b2c96fa616552","repo":"santifer/career-ops","slug":"greenhouse-invalid-url-url","errorCode":null,"errorMessage":"greenhouse: invalid URL: ${url}","messagePattern":"greenhouse: invalid URL: (.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"providers/greenhouse.mjs","lineNumber":20,"sourceCode":"/** @typedef {import('./_types.js').Provider} Provider */\n\n// Greenhouse provider — hits the public boards-api JSON endpoint.\n// Handles both explicit `api:` URLs and auto-detection from `careers_url`.\n\nconst ALLOWED_GREENHOUSE_HOSTS = new Set([\n  'boards-api.greenhouse.io',\n  'boards.greenhouse.io',\n  'job-boards.greenhouse.io',\n  'job-boards.eu.greenhouse.io',\n]);\n\n/** @param {string} url */\nfunction assertGreenhouseUrl(url) {\n  let parsed;\n  try {\n    parsed = new URL(url);\n  } catch {\n    throw new Error(`greenhouse: invalid URL: ${url}`);\n  }\n  if (parsed.protocol !== 'https:') throw new Error(`greenhouse: URL must use HTTPS: ${url}`);\n  if (!ALLOWED_GREENHOUSE_HOSTS.has(parsed.hostname))\n    throw new Error(`greenhouse: untrusted hostname \"${parsed.hostname}\" — must be one of: ${[...ALLOWED_GREENHOUSE_HOSTS].join(', ')}`);\n  return url;\n}\n\n/** @param {import('./_types.js').PortalEntry} entry */\nfunction resolveApiUrl(entry) {\n  if (entry.api) {\n    assertGreenhouseUrl(entry.api);\n    return entry.api;\n  }\n  const url = entry.careers_url || '';\n  const match = url.match(/job-boards(?:\\.eu)?\\.greenhouse\\.io\\/([^/?#]+)/);\n  if (match) return `https://boards-api.greenhouse.io/v1/boards/${match[1]}/jobs`;\n  return null;\n}","sourceCodeStart":2,"sourceCodeEnd":38,"githubUrl":"https://github.com/santifer/career-ops/blob/9b17a8ac97b398a496b38e423ae24e433b43254f/providers/greenhouse.mjs#L2-L38","documentation":"greenhouse.mjs throws this inside assertGreenhouseUrl() when `new URL(url)` raises — the string is not an absolute, parseable URL. The guard validates both the operator-supplied entry.api and the Greenhouse boards-API URL derived from careers_url, so a live throw typically comes from a malformed api: value in portals.yml (or a corrupted derived URL).","triggerScenarios":"A portals.yml entry sets api to a relative path, a string with spaces/unclosed quotes, or empty; entry.api came from an env var that resolved to a non-URL; a test calls assertGreenhouseUrl('/v1/boards/acme/jobs').","commonSituations":"Operator points a Greenhouse entry at a custom API URL but types a path-only value; a CI-generated portals.yml left api blank; an accidental edit introduced whitespace/quotes.","solutions":["Set api to a full absolute URL (e.g. https://boards-api.greenhouse.io/v1/boards/acme/jobs) or remove it and let the provider derive from careers_url.","Check the YAML around api: for stray quotes/spaces.","If api is env-driven, confirm it resolves to a real URL in the target environment."],"exampleFix":"# before\n- name: Acme\n  provider: greenhouse\n  api: /v1/boards/acme/jobs   # relative -> throws\n\n# after\n- name: Acme\n  provider: greenhouse\n  api: https://boards-api.greenhouse.io/v1/boards/acme/jobs","handlingStrategy":"validation","validationCode":"// Pre-flight: validate a Greenhouse entry.api (or derived URL) is an absolute URL.\nfunction greenhouseApiIsValid(entry) {\n  const api = entry.api;\n  if (!api) return true; // will be derived from careers_url\n  try { new URL(api); return true; } catch { return false; }\n}","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Prefer letting the provider derive the API URL from careers_url; only set api when you have a verified boards-api URL.","Add a portals.yml lint pass that calls greenhouseApiIsValid on every provider: greenhouse entry.","Reject non-URL api values in any config validator."],"tags":["url-validation","greenhouse","config","portals-yml"],"backgroundTag":null,"analyzedSha":"9b17a8ac97b398a496b38e423ae24e433b43254f","analyzedAt":"2026-08-13T00:48:39.135Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}