{"record":{"id":"554936554262f3e8","repo":"santifer/career-ops","slug":"rippling-cannot-derive-api-url-for-entry-name","errorCode":null,"errorMessage":"rippling: cannot derive API URL for ${entry.name}","messagePattern":"rippling: cannot derive API URL for (.+?)","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"providers/rippling.mjs","lineNumber":74,"sourceCode":"  if (parsed.protocol !== 'https:') throw new Error(`rippling: URL must use HTTPS: ${url}`);\n  if (parsed.hostname !== API_HOST) {\n    throw new Error(`rippling: untrusted hostname \"${parsed.hostname}\" — must be ${API_HOST}`);\n  }\n  return url;\n}\n\n/** @type {Provider} */\nexport default {\n  id: 'rippling',\n\n  detect(entry) {\n    const slug = resolveSlug(entry);\n    return slug ? { url: apiUrlForSlug(slug) } : null;\n  },\n\n  async fetch(entry, ctx) {\n    const slug = resolveSlug(entry);\n    if (!slug) throw new Error(`rippling: cannot derive API URL for ${entry.name}`);\n    const apiUrl = apiUrlForSlug(slug);\n    assertRipplingApiUrl(apiUrl);\n    // redirect:'error' prevents SSRF via server-side redirects\n    const json = await ctx.fetchJson(apiUrl, { redirect: 'error' });\n    return parseRipplingResponse(json, entry.name);\n  },\n};\n\n/**\n * Parse a Rippling board API response. Exported for unit tests.\n *\n * The response is a top-level JSON ARRAY of postings. Field mapping → the\n * normalized Job shape:\n *   - title:    `name`, trimmed (postings without one are dropped).\n *   - url:      `url` — an absolute `https:` posting URL host-locked to\n *               `ats.rippling.com` (Rippling always serves postings there, so an\n *               off-host or non-https URL is untrusted and the posting is dropped).\n *               It is the dedup key and is display-only (written to the","sourceCodeStart":56,"sourceCodeEnd":92,"githubUrl":"https://github.com/santifer/career-ops/blob/9b17a8ac97b398a496b38e423ae24e433b43254f/providers/rippling.mjs#L56-L92","documentation":"fetch() throws when resolveSlug(entry) returns null. resolveSlug reads entry.careers_url, requires https:, requires hostname exactly 'ats.rippling.com', extracts the first path segment as the slug, and validates it against SLUG_RE (/^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/). Null is returned for a missing/non-HTTPS/wrong-host URL, an empty path segment, or a slug with illegal characters.","triggerScenarios":"careers_url is missing or not https; hostname is not ats.rippling.com (e.g. a branded Rippling domain); the first path segment is empty (bare https://ats.rippling.com); the slug contains underscores, dots, or special characters that fail SLUG_RE; the slug starts/ends with a hyphen.","commonSituations":"A Rippling board uses a custom domain instead of ats.rippling.com; the careers_url points at the jobs page with no slug in the path; the entry was constructed with a non-standard URL format.","solutions":["Set careers_url to the canonical https://ats.rippling.com/<slug>/jobs where <slug> is alphanumeric (plus interior hyphens), starting and ending alphanumeric.","Call detect(entry) before fetch; it returns null cleanly when the slug cannot be resolved.","Verify the first path segment is the tenant slug and is non-empty.","If the board uses a branded domain, the rippling provider cannot auto-derive it — supply the ats.rippling.com URL or use the correct provider."],"exampleFix":"// before\n{ name: 'Acme', careers_url: 'https://jobs.acme.com' }\n// after\n{ name: 'Acme', careers_url: 'https://ats.rippling.com/acme/jobs' }","handlingStrategy":"validation","validationCode":"const CAREERS_HOST = 'ats.rippling.com';\nconst SLUG_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/;\nfunction canResolveRipplingSlug(entry) {\n  const raw = typeof entry.careers_url === 'string' ? entry.careers_url : '';\n  if (!raw) return false;\n  try {\n    const p = new URL(raw);\n    if (p.protocol !== 'https:' || p.hostname !== CAREERS_HOST) return false;\n    const seg = p.pathname.split('/').filter(Boolean)[0] || '';\n    return SLUG_RE.test(seg);\n  } catch { return false; }\n}\nif (!canResolveRipplingSlug(entry)) {\n  console.warn(`skip ${entry.name}: no valid rippling slug in careers_url`);\n}","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}: careers_url must be https://ats.rippling.com/<slug>/jobs`);\n  } else throw e;\n}","preventionTips":["Set careers_url to https://ats.rippling.com/<slug>/jobs with an alphanumeric slug.","Gate fetch() on detect() returning non-null.","Branded Rippling domains cannot be auto-derived — use the ats.rippling.com URL."],"tags":["config","provider","rippling","missing-url","resolution","slug-validation"],"backgroundTag":null,"analyzedSha":"9b17a8ac97b398a496b38e423ae24e433b43254f","analyzedAt":"2026-08-13T00:48:39.135Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}