{"record":{"id":"90aa38445421ab17","repo":"santifer/career-ops","slug":"breezy-cannot-derive-api-url-for-entry-name","errorCode":null,"errorMessage":"breezy: cannot derive API URL for ${entry.name}","messagePattern":"breezy: cannot derive API URL for (.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"providers/breezy.mjs","lineNumber":66,"sourceCode":"    return null;\n  }\n  if (parsed.protocol !== 'https:') return null;\n  if (!BREEZY_HOST_RE.test(parsed.hostname)) return null;\n  return `https://${parsed.hostname}`;\n}\n\n/** @type {Provider} */\nexport default {\n  id: 'breezy',\n\n  detect(entry) {\n    const origin = resolveOrigin(entry);\n    return origin ? { url: `${origin}/json` } : null;\n  },\n\n  async fetch(entry, ctx) {\n    const origin = resolveOrigin(entry);\n    if (!origin) throw new Error(`breezy: cannot derive API URL for ${entry.name}`);\n    const apiUrl = `${origin}/json`;\n    assertBreezyUrl(apiUrl);\n    // redirect:'error' + the host check above keep the final hostname pinned to\n    // the tenant — a server-side redirect can't bounce us off-domain (SSRF).\n    const json = /** @type {any} */ (await ctx.fetchJson(apiUrl, { redirect: 'error' }));\n    return parseBreezyResponse(json, entry.name);\n  },\n};\n\n/**\n * Parse a Breezy `<tenant>.breezy.hr/json` response. Exported for unit tests.\n *\n * Breezy returns a top-level array of positions:\n *   [{ name, url, published_date?,\n *      location: { name?, city?, state?, country?: { name }, is_remote? } }]\n *\n * - url: Breezy supplies an absolute posting URL on the tenant domain\n *   (`https://<tenant>.breezy.hr/p/<id>-<slug>`); it is the Job contract's dedup","sourceCodeStart":48,"sourceCodeEnd":84,"githubUrl":"https://github.com/santifer/career-ops/blob/9b17a8ac97b398a496b38e423ae24e433b43254f/providers/breezy.mjs#L48-L84","documentation":"Thrown by the breezy provider's fetch() when resolveOrigin(entry) returns null — i.e. the portal entry has no usable Breezy tenant origin. resolveOrigin honours an explicit entry.api, else parses entry.careers_url, and requires an https URL whose hostname matches /^[a-z0-9][a-z0-9-]*\\.breezy\\.hr$/ (a real per-tenant subdomain). The same condition makes detect() return null, so seeing this at runtime means fetch() was driven on an entry that never auto-detected as Breezy in the first place.","triggerScenarios":"entry.api and entry.careers_url are both empty/missing; or the configured URL is not https; or its hostname is not a literal <tenant>.breezy.hr subdomain (e.g. a branded CNAME like careers.company.com, or a bare 'breezy.hr' with no tenant).","commonSituations":"Pointing careers_url at the company's branded careers CNAME instead of the canonical <tenant>.breezy.hr host; copying an http:// URL from an older bookmark; omitting both api: and careers_url on a portals.yml entry whose provider is pinned to breezy.","solutions":["Set careers_url (or api:) to the canonical Breezy board URL, e.g. https://acme.breezy.hr — the hostname must be <tenant>.breezy.hr over https.","If the company fronts Breezy on a branded domain, find the underlying <tenant>.breezy.hr host (the /json feed only exists there) and put that in api:.","Gate the entry with provider.detect(entry) before calling fetch() so misconfigured entries are skipped instead of throwing."],"exampleFix":"# before (branded CNAME — will not resolve)\n- name: Acme\n  provider: breezy\n  careers_url: https://careers.acme.com\n\n# after (canonical tenant host)\n- name: Acme\n  provider: breezy\n  careers_url: https://acme.breezy.hr","handlingStrategy":"validation","validationCode":"// detect() runs the identical resolveOrigin check and returns null when fetch() would throw.\nimport breezy from './providers/breezy.mjs';\nif (!breezy.detect(entry)) {\n  // entry.api/careers_url is missing or not https://<tenant>.breezy.hr — skip or fix config\n}","typeGuard":"/** True when entry can yield a Breezy tenant origin (fetch() will not throw 160). */\nfunction isBreezyEntry(entry) {\n  const raw = (typeof entry?.api === 'string' ? entry.api : '') || (typeof entry?.careers_url === 'string' ? entry.careers_url : '');\n  if (!raw.trim()) return false;\n  try {\n    const u = new URL(raw);\n    return u.protocol === 'https:' && /^[a-z0-9][a-z0-9-]*\\.breezy\\.hr$/.test(u.hostname);\n  } catch { return false; }\n}","tryCatchPattern":"// Prefer validation (above). If you must catch, match by provider prefix so you don't swallow unrelated errors.\ntry {\n  await breezy.fetch(entry, ctx);\n} catch (e) {\n  if (String(e.message).startsWith('breezy: cannot derive API URL')) {\n    // config issue — log entry.name and continue, do not retry\n  } else { throw e; }\n}","preventionTips":["Run provider.detect(entry) on every entry before fetch() and skip non-detecting ones.","Validate portals.yml entries at startup against each provider's host pattern.","Keep branded CNAMEs out of breezy entries — only <tenant>.breezy.hr resolves."],"tags":["config","breezy","ats","url-resolution","ssrf"],"backgroundTag":null,"analyzedSha":"9b17a8ac97b398a496b38e423ae24e433b43254f","analyzedAt":"2026-08-13T00:48:39.135Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}