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
- 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.
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
- 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.
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
- greenhouse: URL must use HTTPS: ${url}
- glints: invalid URL: ${url}
- glints: URL must use HTTPS: ${url}
- greenhouse: untrusted hostname "${parsed.hostname}" — must b
- softgarden: cannot resolve widget URL for ${entry.name}
AI-assisted analysis of santifer/career-ops@9b17a8ac97 (2026-08-13).
Data as JSON: /api/errors/ed0b2c96fa616552.
Report an issue: GitHub.