santifer/career-ops · error · Error

greenhouse: invalid URL: ${url}

Error message

greenhouse: invalid URL: ${url}

What it means

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).

Source

Thrown at providers/greenhouse.mjs:20

/** @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;
}

View on GitHub (pinned to 9b17a8ac97)

Solutions

  1. 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.
  2. Check the YAML around api: for stray quotes/spaces.
  3. If api is env-driven, confirm it resolves to a real URL in the target environment.

Example fix

# before
- name: Acme
  provider: greenhouse
  api: /v1/boards/acme/jobs   # relative -> throws

# after
- name: Acme
  provider: greenhouse
  api: https://boards-api.greenhouse.io/v1/boards/acme/jobs
Defensive patterns

Strategy: validation

Validate before calling

// Pre-flight: validate a Greenhouse entry.api (or derived URL) is an absolute URL.
function greenhouseApiIsValid(entry) {
  const api = entry.api;
  if (!api) return true; // will be derived from careers_url
  try { new URL(api); return true; } catch { return false; }
}

Prevention

When it happens

Trigger: 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').

Common situations: 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.

Related errors


AI-assisted analysis of santifer/career-ops@9b17a8ac97 (2026-08-13). Data as JSON: /api/errors/ed0b2c96fa616552. Report an issue: GitHub.